緊急特集RAG × Jev ― ベクトル検索の次を、いま設計する(全5章)

URGENT FEATURECHAPTER 05 / 05

そのまま試せる
RAG×Jev統合の実装コード

最終章は手を動かす回です。Jevで探索し、原文を取り出し、LLMで答える。その中核を、標準ライブラリだけで動くPythonコードで示します。

公開 2026年11月3日読了目安 12分キーワード RAG/Jev/Corpus2Skill
  1. 概要
  2. 第1章
  3. 第2章
  4. 第3章
  5. 第4章
  6. 第5章
Pythonコードが表示された2台のモニターとコーヒーカップのある開発デスク
この章の要点
  • Corpus2Skillのコンパイル出力をJev用の判定表に変換し、探索を2回のJev呼び出しで終えます。
  • JevはSDKに頼らず、REST(/v1/decide)を標準ライブラリだけで呼ぶ形にして、エンドポイントを差し替えやすくします。
  • 回答を書くのは最後の1回の生成LLMだけです。原文全文と質問を渡し、根拠のdoc_idを必ず付けさせます。
  • 閾値は自社QAセットで校正し、コストはusage.cost_usdを集計して実測します。

最終章では、ここまでの設計を実際に動くコードに落とし込みます。第4章で示した3ホップのパイプラインのうち、Jevで探索して原文を取り出す部分と、その原文をもとに生成LLMが回答を書く部分を、Pythonの標準ライブラリだけで実装します。「RAGにJevを組み込みたいが、どこから手を付ければよいか」という方が、手元の文書で小さく試せる状態をこの章のゴールとします。

なお、元にした設計案では非公式のSDKを前提にしていましたが、本章ではそのSDKに依存しません。TypeSafe AIのJevは公式API、Vercel AI Gateway、OpenRouter、再販事業者など複数の経路で提供されており、経路ごとにSDKやURLが異なります。そこで、型付きの質問を送って型付きの回答を受け取るというJevの共通仕様だけを使い、接続先は環境変数で切り替える形にしました。

前提とディレクトリ構成

用意するもの

必要なものは三つです。一つ目は、社内文書をCorpus2Skillでコンパイルした出力です。コンパイルの手順は当サイトの「導入手順」で解説しています。二つ目はJevのAPIキー、三つ目は回答生成に使うLLMのAPIキーです。Pythonは3.10以上を想定し、追加のライブラリは使いません。

セットアップ(シェル)
# 1) 社内文書をスキルツリーへコンパイル(分岐率10、トップレベルは最大8)
python -m corpus2skill --input ./docs --output ./compiled --p 10 --max-top 8

# 2) Jev 用の判定表を作る(コンパイルし直したら毎回実行)
python3 build_maps.py ./compiled/.claude/skills ./jev_maps

# 3) キーは環境変数で渡す(コードやリポジトリに書かない)
export TYPESAFE_API_KEY="jv_live_..."      # Jev(再販エンドポイントの例)
export JEV_DECIDE_URL="https://jevtypesafeai.com/api/v1/decide"
export ANTHROPIC_API_KEY="sk-ant-..."      # 回答生成用
python3 answer.py "インボイスを再発行する手順を教えてください"

Corpus2Skillの--pは1つのノードが持つ子の数(分岐率)、--max-topはトップレベルのスキル数の上限です。LLMで探索する場合は、一度に読ませる選択肢が多すぎると判断が鈍るため、木を深くする方向に調整するのが一般的でした。Jevで探索する場合は1回のchoiceで最大255個の選択肢を扱えるので、第3章で述べたとおり、浅く広い木でも問題ありません。

ディレクトリ構成

ディレクトリ構成
rag-jev/
├── compiled/                  # Corpus2Skill のコンパイル出力
│   ├── .claude/skills/        #   SKILL.md と INDEX.md の階層
│   └── documents.json         #   チャンク分割していない原文
├── jev_maps/                  # build_maps.py が作る判定表
│   ├── cluster_criteria.json
│   └── index_map.json
├── build_maps.py              # 前処理(コンパイルのたびに1回)
├── jev_client.py              # Jev /v1/decide の薄いクライアント
├── navigator.py               # 2ホップ探索(System 1)
├── answer.py                  # 回答生成(System 2)
├── calibrate.py               # 閾値校正とコスト集計
└── qa/benchmark.jsonl         # 自社QAセット
表1:各ファイルの役割と実行タイミング
ファイル役割実行タイミング呼ぶAPI
build_maps.pyスキルツリーをJev用の判定表へ変換コンパイルのたびなし
jev_client.py/v1/decide の呼び出しと再試行毎クエリJev
navigator.py2ホップ探索と原文の取り出し毎クエリJev(2回)
answer.py原文と質問から回答を生成毎クエリ生成LLM(1回)
calibrate.py閾値の校正とコスト集計導入時・再コンパイル時Jev
オフライン(コンパイルのたびに1回) Corpus2Skill 出力 skills / documents.json build_maps.py 前処理 判定表 JSON cluster_criteria / index_map オンライン(質問のたびに実行) answer.py 回答生成(System 2) navigator.py 2ホップ探索 原文全文 jev_client.py → Jev choice / noul(System 1)
図1:モジュール構成。Jevは探索だけ、生成LLMは回答だけを担当します。

前処理で判定表を作る

Corpus2Skillの出力は、LLMエージェントが読むためのMarkdownの木です。各階層のSKILL.mdにはその枝の要約が、INDEX.mdには配下の文書やサブトピックの一覧が書かれています。Jevに渡すには、これを「選択肢ラベルと判定基準の対応表」に変換しておく必要があります。

変換の考え方は単純です。トップレベルのスキルを1つのクラスタとみなし、そのSKILL.mdの冒頭を判定基準(criteria)にします。配下にあるINDEX.mdはすべて読み、文書IDとタイトルの組を平坦化してクラスタに紐づけます。木の中間層をあえて捨て、2階層に畳むのがポイントです。Jevは選択肢が多くても判断速度が落ちにくいため、何段も降りるより、1回で広く選ぶほうが速く済みます。

build_maps.py
"""build_maps.py — Corpus2Skill のコンパイル出力から Jev 用の判定表を作る前処理

出力:
  cluster_criteria.json  {cluster_id: 判定基準の説明}   ← Hop1 の choice に渡す
  index_map.json         {cluster_id: {doc_id: タイトル/要約}} ← Hop2 の noul に渡す
INDEX.md の書式は Corpus2Skill のバージョンや設定で変わり得るため、
DOC_LINE の正規表現は自社の出力を開いて合わせてください。
"""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

DOC_LINE = re.compile(r"^\s*[-*]\s*`?(?P<doc_id>[\w\-.:]+)`?\s*[::|-]\s*(?P<title>.+)$")


def first_paragraph(md: str, limit: int = 400) -> str:
    body = re.sub(r"^---.*?---\s*", "", md, flags=re.S)       # front matter を除く
    lines = [ln.strip() for ln in body.splitlines() if ln.strip() and not ln.startswith("#")]
    return " ".join(lines)[:limit]


def main(skills_root: str, out_dir: str) -> None:
    root, out = Path(skills_root), Path(out_dir)
    criteria: dict[str, str] = {}
    index_map: dict[str, dict[str, str]] = {}
    for top in sorted(p for p in root.iterdir() if p.is_dir()):   # トップレベルスキル = クラスタ
        skill_md = top / "SKILL.md"
        if not skill_md.exists():
            continue
        cid = top.name
        criteria[cid] = first_paragraph(skill_md.read_text(encoding="utf-8"))
        docs: dict[str, str] = {}
        for index_md in top.rglob("INDEX.md"):                     # 配下のリーフを平坦化
            for line in index_md.read_text(encoding="utf-8").splitlines():
                m = DOC_LINE.match(line)
                if m:
                    docs[m.group("doc_id")] = m.group("title").strip()[:200]
        index_map[cid] = docs
    if len(criteria) > 254:
        raise SystemExit("choice の選択肢は255個まで(other を含む)。--max-top を見直してください")
    criteria["other"] = "上記のどの分野にも当てはまらない質問、または社内文書では答えられない質問"
    out.mkdir(parents=True, exist_ok=True)
    (out / "cluster_criteria.json").write_text(json.dumps(criteria, ensure_ascii=False, indent=2), "utf-8")
    (out / "index_map.json").write_text(json.dumps(index_map, ensure_ascii=False, indent=2), "utf-8")
    print(f"clusters={len(criteria) - 1} docs={sum(map(len, index_map.values()))}")


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])
INDEX.mdの書式は自社の出力で確認する

INDEX.mdの行の書き方は、Corpus2Skillのバージョンや--compactの有無で変わる可能性があります。上のコードのDOC_LINEは一例です。実際のファイルを開き、文書IDとタイトルが取れるよう正規表現を合わせてください。取り出した件数を毎回表示しているのは、書式のずれで文書が0件になる事故にすぐ気付くためです。

判定基準の文章は、Jevの精度を左右する最大の要素です。SKILL.mdの要約はLLMが自動生成したもので、「どんな質問ならこの枝か」という判定の観点が弱いことがあります。第4章で述べた社内略語への弱さに対処するため、生成後のcluster_criteria.jsonを人が見直し、社内システム名の定義や「この分野に含めないもの」を書き足しておくと効果的です。

Jevクライアントを書く

リクエストとレスポンスの形

Jevの呼び出しは、state(判断の材料)とquestions(型付きの質問の集まり)を1回のPOSTで送るだけです。質問の型は、選択肢から1つ選ぶchoice、段階評価のscore、はい・いいえの確率を返すnoulなどがあり、複数の質問は並列に評価されます。出力は文章ではなく、ラベルと確率です。

リクエスト例(POST /api/v1/decide)
{
  "model": "jev-1.13.0",
  "state": "インボイスを再発行する手順を教えてください",
  "questions": {
    "target_cluster": {
      "type": "choice",
      "instructions": "この質問に答えるために参照すべき知識クラスタを1つ選ぶ。",
      "criteria": {
        "billing": "請求・支払い・インボイス発行に関する規定と手順",
        "hr": "就業規則・休暇・給与・福利厚生",
        "other": "上記のどれにも当てはまらない質問"
      }
    },
    "is_urgent": { "type": "noul", "instructions": "質問者は至急の対応を求めている。" }
  }
}
レスポンス例(数値は説明用)
{
  "model": "jev-1.13.0",
  "answers": {
    "target_cluster": {
      "choice": "billing",
      "confidence": 0.91,
      "probabilities": { "billing": 0.91, "hr": 0.02, "other": 0.07 }
    },
    "is_urgent": { "noul": 0.18 }
  },
  "usage": { "input_tokens": 412, "cost_usd": 0.00017, "credits_remaining_usd": 4.83 }
}

課金は入力トークンに対してのみ発生し、出力は無料です。レスポンスのusageに、その呼び出しの費用(cost_usd)と残高(credits_remaining_usd)が毎回返るため、コスト計測をアプリ側で積み上げるのが容易です。

再試行とエラー処理

jev_client.py
"""jev_client.py — Jev の /v1/decide を標準ライブラリだけで呼ぶ薄いクライアント"""
from __future__ import annotations

import json
import os
import time
import urllib.error
import urllib.request

# 既定は再販 JevTypeSafeAI.com。公式API・互換ゲートウェイを使う場合は環境変数で差し替える
JEV_DECIDE_URL = os.environ.get("JEV_DECIDE_URL", "https://jevtypesafeai.com/api/v1/decide")
JEV_MODEL = os.environ.get("JEV_MODEL", "jev-1.13.0")  # 本番はバージョン固定(閾値がずれないように)
JEV_API_KEY = os.environ.get("TYPESAFE_API_KEY", "")
RETRYABLE = {502, 503, 504}


class JevError(RuntimeError):
    def __init__(self, status: int, body: str):
        super().__init__(f"Jev HTTP {status}: {body[:200]}")
        self.status = status


def decide(state, questions: dict, *, timeout: float = 10.0, retries: int = 2) -> dict:
    """state と型付き質問を送り、{"answers": ..., "usage": ...} を返す。"""
    payload = json.dumps({"model": JEV_MODEL, "state": state, "questions": questions},
                         ensure_ascii=False).encode("utf-8")
    req = urllib.request.Request(
        JEV_DECIDE_URL, data=payload, method="POST",
        headers={"Authorization": f"Bearer {JEV_API_KEY}",
                 "Content-Type": "application/json",
                 "User-Agent": "rag-jev-navigator/1.0"})
    for attempt in range(retries + 1):
        try:
            with urllib.request.urlopen(req, timeout=timeout) as res:
                return json.loads(res.read().decode("utf-8"))
        except urllib.error.HTTPError as exc:
            body = exc.read().decode("utf-8", "replace")
            # 401=キー不正 / 402=残高不足 / 403=停止 / 413=入力過大 は再試行しない
            if exc.code not in RETRYABLE or attempt == retries:
                raise JevError(exc.code, body) from exc
        except urllib.error.URLError:
            if attempt == retries:
                raise
        time.sleep(0.5 * (2 ** attempt))  # 502 などは指数バックオフ
    raise RuntimeError("unreachable")
表2:Jev APIの主なエラーと扱い方
HTTP意味再試行アプリ側の対応
401APIキーが不正しないキーの形式と接続先の組み合わせを確認
402残高不足しない別経路へ切り替えるか、従来RAGに縮退
403アカウント停止しない402と同じく縮退し、管理者へ通知
413入力が大きすぎるしない候補文書をさらに小さいバッチに分割
502上流のエラーする指数バックオフで再試行

当サイトの運営でも、日本語ページの広告位置をJevで判定する仕組みを動かしています。そこでは、主に使う経路の残高が尽きたりキーが無効になったりした場合に、OpenRouter経由のJev(typesafe/jev-1.14)へ自動で切り替える実装にしています。Jevを本番の判断経路に置くなら、このように別経路を一つ用意しておくと停止に強くなります。

ナビゲーターの実装

中核となるnavigator.pyです。元の設計案にあった関数を、確認済みのREST仕様に合わせて移植しました。処理は2回のJev呼び出しで完結します。

navigator.py
"""navigator.py — Jev でスキルツリーを2ホップ探索し、原文全文を取り出す"""
from __future__ import annotations

import json
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

from jev_client import decide

DATA = Path("./jev_maps")
CLUSTER_CRITERIA: dict[str, str] = json.loads((DATA / "cluster_criteria.json").read_text("utf-8"))
INDEX_MAP: dict[str, dict[str, str]] = json.loads((DATA / "index_map.json").read_text("utf-8"))
# Corpus2Skill の documents.json(チャンク分割されていない原文)。形式は {doc_id: 本文} を想定
DOCUMENTS: dict[str, str] = json.loads(Path("./compiled/documents.json").read_text("utf-8"))

DOC_BATCH = 40  # 入力上限(state+全質問で64kトークン)を超えないよう候補を分割


def _noul_batch(query: str, items: list[tuple[str, str]]) -> dict[str, float]:
    questions = {
        doc_id: {"type": "noul",
                 "instructions": f"この質問に正しく答えるには、文書『{summary}』の本文を読む必要がある。"}
        for doc_id, summary in items
    }
    res = decide(query, questions)
    return {doc_id: float(a.get("noul", 0.0)) for doc_id, a in res.get("answers", {}).items()}


def navigate(query: str, *, high_conf: float = 0.75, min_conf: float = 0.35,
             branch_cutoff: float = 0.15, doc_threshold: float = 0.55, top_k: int = 3) -> dict:
    # ---- Hop 1: クラスタ選択(choice)と緊急度(noul)を1リクエストで同時に判定 ----
    hop1 = decide(query, {
        "target_cluster": {
            "type": "choice",
            "instructions": "この質問に答えるために参照すべき知識クラスタを1つ選ぶ。",
            "criteria": CLUSTER_CRITERIA,
        },
        "is_urgent": {
            "type": "noul",
            "instructions": "質問者は業務停止や期限切迫により、至急の対応を求めている。",
        },
    })
    ans = hop1["answers"]["target_cluster"]
    is_urgent = float(hop1["answers"]["is_urgent"].get("noul", 0.0))
    choice, conf = ans["choice"], float(ans.get("confidence", 0.0))

    # ゲート1: 該当なし・確信度が極端に低い → 生成LLMを呼ばずに打ち切る
    if choice == "other" or conf < min_conf:
        return {"status": "abstain", "documents": {}, "is_urgent": is_urgent, "confidence": conf}

    # ゲート2: 高確信なら単独、中確信なら上位最大3クラスタへ投機的ファンアウト
    if conf >= high_conf:
        clusters = [choice]
    else:
        ranked = sorted(ans.get("probabilities", {}).items(), key=lambda kv: kv[1], reverse=True)
        clusters = [cid for cid, p in ranked if cid != "other" and p >= branch_cutoff][:3] or [choice]

    # ---- Hop 2: 候補文書ごとの要否を noul で並列判定 ----
    candidates: dict[str, str] = {}
    for cid in clusters:
        candidates.update(INDEX_MAP.get(cid, {}))
    items = list(candidates.items())
    batches = [items[i:i + DOC_BATCH] for i in range(0, len(items), DOC_BATCH)]
    scores: dict[str, float] = {}
    with ThreadPoolExecutor(max_workers=4) as pool:
        for part in pool.map(lambda b: _noul_batch(query, b), batches):
            scores.update(part)

    picked = sorted((d for d in scores if scores[d] >= doc_threshold),
                    key=lambda d: scores[d], reverse=True)[:top_k]
    docs = {d: DOCUMENTS[d] for d in picked if d in DOCUMENTS}
    return {
        "status": "success" if docs else "no_matching_doc",
        "documents": docs,
        "clusters": clusters,
        "scores": {d: scores[d] for d in picked},
        "is_urgent": is_urgent,
        "confidence": conf,
    }
ユーザー navigator Jev API documents.json 生成LLM 質問 Hop1 choice+noul クラスタと確信度 ゲート/ファンアウト Hop2 noul×N 並列 文書ごとの確率 上位3件の原文を取得 原文+質問 回答と出典doc_id
図2:1回の質問の流れ。Jevへの往復は2回で、文章を書くのは最後の生成LLMだけです。

閾値パラメータの意味

表3:navigate() の閾値と調整の方向
引数既定値役割上げると
min_conf0.35これ未満は回答を控える誤答は減るが「分かりません」が増える
high_conf0.75これ以上は単独クラスタへ直行ファンアウトが増え、取りこぼしが減る
branch_cutoff0.15ファンアウト先に含める確率の下限並列探索する枝が減り、候補が絞られる
doc_threshold0.55原文を読ませる文書の下限LLMへの入力は減るが、根拠不足が増える

既定値はあくまで出発点です。とくに日本語の文書では、noulの値が狭い範囲に集まりやすい点に注意してください。当サイトでの運用でも、日本語ページに対するnoulが0.65〜0.71付近に固まる現象が見られ、絶対値で線を引くより、ページ内の相対順位で使うほうが安定しました。doc_thresholdで足切りしたうえで上位3件に絞る実装にしているのは、このためです。

候補文書を40件ずつに分けて並列に送っているのは、入力の上限への対策です。stateと全質問を合わせた入力には上限(標準で64kトークン)があり、大きなクラスタの文書を一度に送ると413エラーになります。バッチを並列に投げれば、件数が増えても待ち時間はほぼ1往復分で済みます。

原文を渡して回答を生成する

探索が終われば、手元には質問に必要な文書が最大3件、チャンクに切られていない原文全文の形で残っています。ここで初めて生成LLMを呼びます。渡すのは原文と質問だけです。検索結果の断片を何十個も詰め込む従来のRAGと比べ、プロンプトが短く、内容の濃い入力になります。

answer.py
"""answer.py — 取り出した原文全文と質問だけを生成LLMに渡して回答を書かせる"""
from __future__ import annotations

import json
import os
import urllib.request

from navigator import navigate

ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"
ANSWER_MODEL = os.environ.get("ANSWER_MODEL", "claude-sonnet-4-6")

SYSTEM = (
    "あなたは社内ナレッジの回答担当です。与えられた<document>の内容だけを根拠に日本語で答えてください。"
    "例外規定・ただし書きがあれば必ず反映し、各文の末尾に根拠の doc_id を [doc_id] 形式で付けます。"
    "文書から答えが分からない場合は、分からないと答えてください。"
)


def _call_llm(question: str, docs: dict[str, str]) -> str:
    context = "\n".join(f'<document id="{d}">\n{text}\n</document>' for d, text in docs.items())
    body = json.dumps({
        "model": ANSWER_MODEL,
        "max_tokens": 1200,
        "system": SYSTEM,
        "messages": [{"role": "user", "content": f"{context}\n\n質問: {question}"}],
    }, ensure_ascii=False).encode("utf-8")
    req = urllib.request.Request(ANTHROPIC_URL, data=body, method="POST", headers={
        "x-api-key": os.environ["ANTHROPIC_API_KEY"],
        "anthropic-version": "2023-06-01",
        "content-type": "application/json",
    })
    with urllib.request.urlopen(req, timeout=60) as res:
        data = json.loads(res.read().decode("utf-8"))
    return "".join(block.get("text", "") for block in data.get("content", []))


def answer(question: str) -> dict:
    nav = navigate(question)
    if nav["status"] != "success":
        text = "社内文書から該当する情報を見つけられませんでした。担当窓口へお問い合わせください。"
    else:
        text = _call_llm(question, nav["documents"])
    return {"answer": text, "sources": list(nav["documents"]), "is_urgent": nav["is_urgent"],
            "confidence": nav["confidence"]}


if __name__ == "__main__":
    import sys
    print(json.dumps(answer(sys.argv[1]), ensure_ascii=False, indent=2))

システムプロンプトで、例外規定やただし書きの反映と、文ごとの出典doc_idの付与を求めています。出典が付いていれば、利用者は疑わしい回答だけを原文で確かめられます。また、探索が打ち切られた場合は生成LLMを呼ばずに定型文を返すため、答えられない質問にコストをかけずに済みます。

評価とコストの計測

Corpus2Skill単体との比較を先に取る

Jevを組み込む効果を示すには、比較の基準が必要です。まずCorpus2Skill標準のLLMナビゲーションで、自社QAセットを評価しておきます。Corpus2Skillには評価モジュールが同梱されており、F1やROUGE、LLM判定による事実性、トークン数とドル換算のコストを一度に記録できます。

基準となる評価(Corpus2Skill標準)
# Corpus2Skill 標準(LLMナビゲーション)での評価。品質指標とトークン・費用が記録される
python -m corpus2skill.eval --output-dir ./compiled --qa ./qa/benchmark.jsonl --output eval_results.json

そのうえで、同じQAセットをJev版で流し、正しい文書に到達できたか(コンテキスト再現率)と、Jev側の1クエリあたり費用を測ります。calibrate.pyはdoc_thresholdを3通りに変えて結果を並べる簡易版です。

calibrate.py
"""calibrate.py — 自社QAセットで Jev の閾値を校正し、Jev 側コストを集計する"""
from __future__ import annotations

import json
import sys

import jev_client
import navigator

_orig_decide = jev_client.decide
COST = {"usd": 0.0, "calls": 0}


def _metered(state, questions, **kw):
    res = _orig_decide(state, questions, **kw)
    COST["usd"] += float((res.get("usage") or {}).get("cost_usd", 0.0))
    COST["calls"] += 1
    return res


navigator.decide = _metered  # navigator 内の呼び出しを計測付きに差し替え


def evaluate(qa_path: str, doc_threshold: float) -> dict:
    recall_sum = scored = total = abstain = 0
    with open(qa_path, encoding="utf-8") as f:
        for line in f:
            qa = json.loads(line)                      # {"question": ..., "doc_ids": [...]}
            nav = navigator.navigate(qa["question"], doc_threshold=doc_threshold)
            total += 1
            abstain += nav["status"] != "success"
            expected = set(qa.get("doc_ids", []))
            if expected:                               # 必要な文書のうち何割を取れたか
                recall_sum += len(set(nav["documents"]) & expected) / len(expected)
                scored += 1
    return {"threshold": doc_threshold, "context_recall": round(recall_sum / max(scored, 1), 3),
            "abstain_rate": round(abstain / max(total, 1), 3),
            "jev_usd_per_query": round(COST["usd"] / max(total, 1), 6)}


if __name__ == "__main__":
    for t in (0.45, 0.55, 0.65):
        COST.update(usd=0.0, calls=0)
        print(evaluate(sys.argv[1], t))
表4:自社QAセットで閾値を校正する手順
手順やること見る指標
1問い合わせ履歴から50〜100問を選び、正解の文書IDを付ける質問の分野が偏っていないか
2Corpus2Skill標準で評価し、基準値を記録事実性、1クエリ費用
3Jev版でdoc_thresholdを振って評価コンテキスト再現率、回答控え率
4再現率が基準を下回る質問を分類し、判定基準の文章を修正誤ったクラスタに行った質問の傾向
5min_confとhigh_confを調整して再評価誤答率と控え率のバランス

コストの見積もり方

Corpus2Skill標準のコストは、論文のWixQAでの計測が目安になります。プロンプトキャッシュなしで1クエリ0.172ドル、キャッシュ有効時で0.089ドルです(詳しくは「コストとキャッシュ」)。Jev版では、探索部分の費用が入力トークン数と単価の掛け算で決まります。次の表は、前提を置いた試算です。

$0.172
Corpus2Skill(WixQA・キャッシュなし)
$0.089
同・キャッシュ有効時
$0.042
Jev公式価格(入力100万トークン、出力無料)
70〜500ms
Jevの応答時間(ベンダー公表値)
表5:Jevによる探索部分の費用試算(1クエリ)
項目前提・計算公式価格再販価格
Hop1判定基準30クラスタ+質問で約3,000入力トークン$0.000126$0.00126
Hop2候補80文書×約90トークン+質問で約8,000入力トークン$0.000336$0.00336
合計約11,000入力トークン、出力は無料約$0.0005約$0.005

この試算では、探索の費用は公式価格で1クエリ約0.05セント、単価が10倍の再販経路でも約0.5セントです。総費用の大半は、最後に原文を読んで回答を書く生成LLMが占めることになります。ただし入力トークンの数え方は経路ごとに異なる可能性があるため、本番の見積もりには必ずusage.cost_usdの実測値を使ってください。参考までに、当サイトで日本語ページ全文と複数の質問をJevに渡す判定を行った際の実費は、1ページあたり約0.0004ドルでした。

応答時間も同様です。ベンダーは70〜500ミリ秒と公表しており、利用者の報告ではp50が約150ミリ秒、p95が約350ミリ秒という例もあります。いずれも独立した大規模検証ではないため、自社のネットワークとデータで、Hop1・Hop2・生成LLMの最初の出力までをそれぞれ計測しておくことを勧めます。

本番化のチェックリスト

表6:本番投入前に確認する項目
観点確認すること
モデル固定JEV_MODELをバージョン固定にし、更新時は閾値を再校正する
縮退経路402・403・タイムアウト時に、別経路のJevか従来のベクトル検索へ落とせる
入力上限最大クラスタの文書数でも413にならないバッチサイズになっている
判定基準社内略語・システム名・除外条件がcluster_criteria.jsonに書かれている
固有名詞社名・製品名の逆引き辞書で候補を補う経路がある(第4章)
再コンパイル文書更新時に、コンパイル→判定表の再生成→回帰評価が一続きで回る
ログ選ばれたクラスタ、確信度、採用doc_id、費用を質問ごとに記録している
秘密情報APIキーは環境変数で渡し、リポジトリやログに残らない

ログの項目は、改善の手がかりとして特に重要です。Jevは判断の理由を文章で返さないため、誤答の原因を追うには「どのクラスタを、どの確信度で選んだか」という数値の記録が頼りになります。確信度が低いまま答えている質問を週に一度見直すだけでも、判定基準の改善点が見えてきます。

この構成が向くユースケース

大規模なカスタマーサポート

製品マニュアルや利用規約のように、目次がはっきりした文書群に向いています。問い合わせ件数が多いほど、探索をJevに任せたことによる費用と待ち時間の削減が効いてきます。Hop1で同時に判定しているis_urgentを使えば、緊急度が高い問い合わせについて、回答を返しながらオペレーターへ通知する連携も簡単に作れます。判定を追加しても、同じ1回のリクエストに質問を足すだけで済むのがJevの利点です。

社内規程・人事労務のナレッジボット

第1章で取り上げた「ただし書き」の問題に最も効く領域です。文書を細切れにせず、規程を条文ごと原文で読ませるため、離れた条項にある例外規定を落としにくくなります。回答に出典doc_idが付くので、人事担当者が根拠をすぐ確認できる点も運用上の安心材料です。

現場作業向けのマニュアル検索

安全手順書や点検マニュアルを、現場のタブレットから引く用途です。探索がミリ秒単位で終わるため、待ち時間の大半は回答文の生成だけになります。回答をストリーミング表示すれば、利用者は数秒待たずに読み始められます。一方で、通信が不安定な現場では生成LLMへの往復そのものが課題になるため、よく引かれる手順は回答を事前に用意しておくなどの併用策が必要です。

表7:ユースケースごとの向き不向き
ユースケース向いている理由注意点
カスタマーサポート件数が多く、費用と速度の効果が大きい製品名の表記揺れを判定基準に書き足す
社内規程・人事労務原文で答えるため例外規定を落としにくい改定のたびに再コンパイルと回帰評価が必要
現場マニュアル探索の待ち時間がほぼ消える通信環境への備えが別途必要
雑多な事実の寄せ集め向かないトピック構造がなく、フラット検索が有利
ナビゲーション型が向かないコーパスもある

Corpus2Skillの論文自身が、オープンドメインの雑多な事実の集まりや、似た形式の表データが並ぶだけのコーパスでは、従来のフラット検索のほうが有利だと報告しています。Jevで探索を速くしても、この性質は変わりません。適用判断の詳細は「エンタープライズ活用」を参照してください。

おわりに:探索と執筆の分業

本特集で見てきた構成の本質は、AIの役割分担にあります。どの棚を開き、どの文書を読むかという有限の選択は、文章を書かないSystem 1のJevが受け持ちます。原文を読み、条件を整理し、根拠付きの文章にまとめる仕事は、System 2の生成LLMが受け持ちます。従来のRAGがベクトル検索の断片に頼って文脈を失い、Corpus2Skill単体が探索のたびにLLMを呼んで遅く高くなっていた問題を、この分業がまとめて解きます。

「RAGを高速化するためにJevを組み込みたい」と考えたとき、ベクトル検索の結果をJevで並べ替える使い方も確かに可能です。しかし、断片化という根本の問題は残ります。Jevの強みが最も生きるのは、Corpus2Skillのスキルツリーという「選択肢のはっきりした地図」の上で、道案内役を務めるときです。

まず小さく検証する3ステップ
  • 文書数百件規模の1部門のコーパスをCorpus2Skillでコンパイルし、標準の評価で基準値を取る。
  • 本章のコードで同じQAセットを流し、到達率・費用・応答時間を並べて比較する。
  • 差が出た質問を見て判定基準を直し、閾値を校正してから対象部門を広げる。

特集全体の要点は特集トップにまとめています。設計の背景に戻りたい方は第4章から、評価指標の読み方は「ベンチマークと評価」からお読みください。

参考資料・情報ソース

  1. Sun, Wei, Hsieh「Corpus2Skill: Distilling Enterprise Knowledge into Navigable Agent Skills for QA and RAG」arXiv:2604.14572(EMNLP 2026 Findings)
  2. Corpus2Skill GitHubリポジトリ(README:コンパイル・評価のCLI、出力構成)
  3. JevTypeSafeAI.com APIドキュメント(/api/v1/decide のリクエスト・レスポンス・エラー・入力上限)
  4. Vercel Changelog「AI Gateway now supports TypeSafe clients and an HTTP API for Jev」(2026年9月21日)
  5. DataCamp「System One モデル Jev」(価格・ベンチマーク・限界の解説)
  6. 「TypeSafe launches Jev, a chatless AI model」(レイテンシ・選択肢数・独立評価の状況)
  7. Let's Data Science「TypeSafe AI Launches Jev for Structured Software Decisions」