要旨
- 人が普段しゃべる指示は曖昧さが混ざりがち。根拠提示RAGアシスタント はその曖昧さを“人間らしく”補完しつつ、STRICT Evidence(根拠提示)を徹底する Custom GPT テンプレートです。
備忘録として記事にしているため、そのままでは動かない可能性もありますので、ご了承ください。- Cloudflare Workers(Runner)+ Workers AI(埋め込み)+ Qdrant Cloud(ベクトルDB) を使い、ChatGPT Actions(OpenAPI) から呼び出して動きます。
- クラウド側は 無料枠(0円) で構築可能(※ChatGPT Plus のサブスクは必要)。
できること(無料枠でここまで)
-
RAG(根拠検索):
knowledgeSearch(q,k)→knowledgeFetch(ids)で本文を取り出し、根拠ベースで回答。 -
知識拡張(Upsert):
knowledgeUpsert({items:[{id?,title,body}]})で自前文書を登録/更新(任意文字 ID も自動正規化)。 -
“作業スキル”の実行:
skill.runで軽作業を確実に実行。例:-
excel_formula:=SUM(A1:A10) などの式生成 -
ic5_outline:要件テキストから IC-5(Goal/Deliverable/Constraints/Defaults/Non-Goals)骨子を抽出 -
todo_extract:テキストから TODO/STEP 行を抽出
-
-
曖昧→補完の運転:
- Clarify-or-Assume ラダー:重要なら最大3問だけ確認→待たずに既定値で0.1試作を即提示→【推測】を台帳に記録。
- Contradiction Check:意味/表現/物理/UI の破綻検査→NGなら自動リトライ。
-
デバッグAPI:
/debug/embed-dim(埋め込み次元)・/debug/qdrant-auth(Qdrant 認証)で即切り分け。
💡 0円の範囲:Workers(Free 日次上限)/Workers AI(Neurons 無料枠)/Qdrant(Free 1GB)。まずは個人開発〜小規模ならクラウド費ゼロで回せます(ChatGPT Plus を除く)。
どう“曖昧さ”を補完するの?(人間の会話に寄せる)
- 人は「明日までに資料まとめて」「ざっくりでいい」「前回の方向性で」など前提共有を省略しがち。
- 根拠提示RAGアシスタント は IC-5 で前提を可視化し、3問以内の確認か、既定値(言語=ja-JP/TZ=Asia/Tokyo/単位=SI/コード=Hardended-Min)で仮置きして0.1試作を先に出します。
- そのうえで RAG で根拠を拾い、Sources に列挙。不確かは 【推測】 と明示します。
全体構成(テキスト図)
Custom GPT (Actions) ──OpenAPI──▶ Cloudflare Worker (Runner)
├─ Workers AI (Embeddings)
└─ Qdrant Cloud (Vector DB)
セットアップ(無料枠で最短)
1) Cloudflare Workers(Runner)
- ダッシュボード → Workers & Pages → Create → Hello World。
- エディタに本稿末尾の
worker.js(完全版)を全貼り → Save → Deploy。 -
Bindings で Workers AI を追加(Name:
AI)。 -
環境変数(Variables & Secrets)
-
QDRANT_URL(RESTエンドポイント、末尾スラなし) -
QDRANT_API_KEY(Secret) -
QDRANT_COLLECTION(例:knowledge) - 任意:
EMBED_MODEL(@cf/baai/bge-m3or@cf/baai/bge-base-ja-v1) - 任意:
CF_ACCOUNT_ID/CF_API_TOKEN(RESTフォールバック)
-
- Preview/HTTP で動作確認:
-
GET /debug/embed-dim→ 200 /dim表示 -
GET /debug/qdrant-auth→ いずれかのヘッダで 200 が出る -
GET /knowledge/seed→upserted: [1,2,3]
-
2) ChatGPT Actions(Custom GPT)
- OpenAPI(下記を貼る):
openapi: 3.1.0
info: { title: RAGランナーAPI, version: "1.2.2" }
servers:
- url: https://<your-worker>.<account>.workers.dev
paths:
/knowledge/seed:
get:
operationId: knowledgeSeed
responses: { "200": { description: OK } }
/knowledge/search:
get:
operationId: knowledgeSearch
parameters:
- in: query
name: q
required: true
schema: { type: string }
- in: query
name: k
schema: { type: integer, default: 5, minimum: 1, maximum: 20 }
responses: { "200": { description: OK } }
/knowledge/fetch:
post:
operationId: knowledgeFetch
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ids]
properties:
ids:
type: array
items: { type: string }
responses: { "200": { description: OK } }
/knowledge/upsert:
post:
operationId: knowledgeUpsert
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
oneOf:
- $ref: "#/components/schemas/Item"
- type: array
items: { $ref: "#/components/schemas/Item" }
responses: { "200": { description: OK } }
/skill/run:
post:
operationId: runSkill
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [skill, input]
properties:
skill: { type: string, enum: [excel_formula, ic5_outline, todo_extract] }
input: { type: object, additionalProperties: true }
responses: { "200": { description: OK } }
components:
schemas:
Item:
type: object
description: 任意文字IDでも可(サーバ側で整数/UUIDに正規化)
properties:
id: { type: string }
title: { type: string }
body: { type: string }
- インストラクション(曖昧→補完 強化版 v1.1):
# 根拠提示RAGアシスタント — Instruction v1.1(曖昧→補完 強化版)
- 返答の骨格:IC-5 → Macro → Micro → 出力(0.1試作) → Assumptions(【推測】) → Risks/Rollback → Sources(最大5)。
- Clarify-or-Assume:重要なら最大3問だけ確認→既定値で仮置き→即 0.1 試作。Contradiction Check を実施し NG は最大2回リトライ。
- Actions:RAG = `knowledgeSearch`→`knowledgeFetch`/Skill = `skill.run({ skill, input })`(excel_formula / ic5_outline / todo_extract)/Upsert = `knowledgeUpsert(items)`。
- コード既定:L2 Hardened-Min(入力検証/例外分類/タイムアウト/レート制限/構造化ログ/最小テスト)。
使い方(最短お試し)
-
Seed:
knowledgeSeedを実行(ID=1,2,3 が入る)。 -
検索→取得:
knowledgeSearch(q="IC-5", k=3)→ 出たid[]をknowledgeFetch。 -
スキル:
-
excel_formula→{ "range": "A1:B10" } -
ic5_outline→{ "text": "Goal: ...\nDeliverables: ..." } -
todo_extract→{ "text": "todo: seed\nstep: fetch" }
-
- 知識拡張:
{
"items": [
{ "id": "my-doc", "title": "IC-5 ガイド", "body": "..." },
{ "title": "章2", "body": "..." }
]
}
→ upsert 後に search→fetch して Sources に ID/要旨を列挙。
無料で作るメリット(アピール)
- 初期費用ゼロ:Runner/Qdrant/埋め込みが無料枠で動く。作って壊して学ぶが気軽。
- 人間らしく前に進む:曖昧な指示でも既定値で 0.1 試作→早い合意形成。
- 根拠でぶれない:RAG と Sources により“言い切り”の暴走を抑制。
- 運用に強い:L2 ハードニング(レート制限/タイムアウト/例外分類/構造化ログ)。
-
拡張しやすい:
/skill/runに関数を足していくだけで作業の自動化を増やせる。
よく出るエラーと即復旧
-
403(create collection failed):Qdrant 認証/URL。不明なら
/debug/qdrant-authを実行→200 が出るヘッダ形式で固定。 -
500(embedding failed):Workers AI Binding 未設定 or モデル名違い。
/debug/embed-dimで確認。必要ならCF_ACCOUNT_ID/CF_API_TOKENを設定して REST フォールバック。 -
OpenAPI 赤線:
components:はpaths:と同階層に。インライン{}を減らし多段で書く。 -
429(rate limit):
/skill/runは 1分30回の簡易制限。負荷テスト時は待つ or 値を調整。
ランナー完全ソース
worker.js
// Golden Runner (Hardened) — Cloudflare Workers AI + Qdrant
// - 任意文字IDでもUUIDに正規化(一般Upsert/Fetch両方)
// - ベクトル次元→自動で次元付きコレクションを作成し、エイリアスへ張替え
// - 認証ヘッダは api-key / Api-Key / Bearer を自動試行
// - 埋め込みは WorkersAI 'text' / 'input' / 'input_text' を順に試し、HTTPフォールバックも可
export default {
async fetch(req, env) {
const url = new URL(req.url);
const path = url.pathname;
// CORS
if (req.method === "OPTIONS") return new Response(null, { headers: cors() });
try {
// ---- Health
if (path === "/" && req.method === "GET") {
return json({ ok: true, service: "runner", now: new Date().toISOString() });
}
// ---- Debug: embed dim
if (path === "/debug/embed-dim" && req.method === "GET") {
const v = await embed(env, "health");
return json({ ok: true, dim: v.length });
}
// ---- Debug: qdrant auth (200/ERR 一覧)
if (path === "/debug/qdrant-auth" && req.method === "GET") {
const base = baseUrl(env);
const results = [];
for (const h of qdrantHeaderVariants(env.QDRANT_API_KEY)) {
try {
const r = await tfetch(`${base}/collections`, { headers: h }, 8000);
results.push({ header: Object.keys(h).find(k=>k!=="content-type"), status: r.status });
} catch (e) {
results.push({ header: Object.keys(h).find(k=>k!=="content-type"), status: "ERR", message: String(e?.message||e) });
}
}
return json({ ok: true, results });
}
// ---- Seed(整数ID 1,2,3 固定:確実に通る)
if (path === "/knowledge/seed" && req.method === "GET") {
const items = [
{ title: "Skills/Actionsの考え方", body: "Actions=外部実行。RAG=一次資料検索。" },
{ title: "Hardened-Min", body: "入力検証/例外/タイムアウト/レート/ログ/最小テスト。" },
{ title: "IC-5", body: "Goal/Deliverable/Constraints/Defaults/Non-Goals。" }
];
const texts = items.map(it => `${it.title}\n${it.body}`);
const vecs = await embedMany(env, texts);
await ensureCollection(env, vecs[0].length);
const points = items.map((it, i) => ({
id: i + 1,
vector: vecs[i],
payload: { ...it, seed_orig: `p${i+1}` }
}));
await qdrantUpsert(env, points);
return json({ ok: true, upserted: points.map(p=>p.id) });
}
// ---- Search
if (path === "/knowledge/search" && req.method === "GET") {
const q = url.searchParams.get("q") || "";
const k = clampInt(url.searchParams.get("k") || "5", 1, 20);
if (!q) return json({ ok:false, error:"q required" }, 400);
const vec = await embed(env, q);
await ensureCollection(env, vec.length);
const hits = await qdrantSearch(env, vec, k);
return json({ ok:true, items: hits.map(h => ({
id: h.id, score: h.score, title: h.payload?.title, body: h.payload?.body
}))});
}
// ---- Fetch(ID正規化して取得)
if (path === "/knowledge/fetch" && req.method === "POST") {
const body = await safeJson(req);
const rawIds = Array.isArray(body?.ids) ? body.ids : [];
if (!rawIds.length) return json({ ok:false, error:"ids required" }, 400);
const ids = await Promise.all(rawIds.map(x => normalizePointId(x)));
const items = await qdrantRetrieve(env, ids);
return json({ ok:true, items });
}
// ---- Upsert(単体/配列 OK・任意文字ID→UUID化)
if (path === "/knowledge/upsert" && req.method === "POST") {
const body = await safeJson(req);
let items = body?.items;
if (!items) return json({ ok:false, error:"items required" }, 400);
if (!Array.isArray(items)) items = [items];
const texts = items.map(it => `${it.title ?? ""}\n${it.body ?? ""}`);
const vecs = await embedMany(env, texts);
await ensureCollection(env, vecs[0].length);
const points = await Promise.all(items.map(async (it, i) => ({
id: it.id ? await normalizePointId(it.id) : cryptoRandomUUID(),
vector: vecs[i],
payload: it
})));
await qdrantUpsert(env, points);
return json({ ok:true, upserted: points.map(p=>p.id) });
}
// ---- /skill/run(L2: Hardened-Min)
if (path === "/skill/run" && req.method === "POST") {
// 入口レート制限(IP×エンドポイント、1分30回)
const ip =
req.headers.get("CF-Connecting-IP") ||
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
"unknown";
const rl = enforceRateLimit(env, `skill:${ip}`, 30, 60_000);
if (!rl.ok) {
return json({ ok: false, code: "RATE_LIMIT", retry_after_ms: rl.retryAfterMs }, 429);
}
// 入力検証
const body = await safeJson(req);
const skill = typeof body?.skill === "string" ? body.skill.trim() : "";
const input = (body && typeof body.input === "object") ? body.input : {};
if (!skill) return json({ ok:false, code:"BAD_REQUEST", message:"skill required" }, 400);
if (JSON.stringify(input).length > 40_000) {
return json({ ok:false, code:"PAYLOAD_TOO_LARGE", limit: 40000 }, 413);
}
// スキル定義
const skills = {
excel_formula: async (inp) => {
const range = typeof inp?.range === "string" ? inp.range : "A1:A10";
return { formula: `=SUM(${range})` };
},
ic5_outline: async (inp) => {
const text = String(inp?.text ?? "");
const keep = (s) => (s || "").toString().slice(0, 4000);
const pick = (re) => keep((text.match(re) || [])[1] || "");
return {
goal: pick(/(?:^|\b)goal[::]\s*(.+)/i),
deliverable: pick(/(?:^|\b)deliverable[s]?[::]\s*(.+)/i),
constraints: pick(/(?:^|\b)constraint[s]?[::]\s*(.+)/i),
defaults: pick(/(?:^|\b)default[s]?[::]\s*(.+)/i),
nonGoals: pick(/(?:^|\b)non[- ]?goal[s]?[::]\s*(.+)/i),
};
},
todo_extract: async (inp) => {
const text = String(inp?.text ?? "");
const lines = text.split(/\r?\n/).map(s=>s.trim()).filter(Boolean);
const prefixRe = /^(?:todo|task|step|action)[::\-]\s*/i;
const items = lines.filter(s => prefixRe.test(s))
.map(s => s.replace(prefixRe, ""))
.slice(0, 50);
return { items };
},
};
if (!Object.prototype.hasOwnProperty.call(skills, skill)) {
return json({ ok:false, code:"SKILL_NOT_FOUND", message:`unknown skill: ${skill}` }, 404);
}
// 実行(タイムアウト/例外分類/構造化ログ)
const TIMEOUT_MS = 5_000;
const exec = skills[skill];
const timer = new Promise((_, rej) => setTimeout(() => rej(new Error("TIMEOUT")), TIMEOUT_MS));
try {
const output = await Promise.race([exec(input), timer]);
console.log(JSON.stringify({ at:"/skill/run", ip, skill, ok:true }));
return json({ ok:true, skill, output });
} catch (e) {
const msg = String(e?.message || e);
const code = msg.includes("TIMEOUT") ? "TIMEOUT"
: msg.includes("RATE") ? "RATE_LIMIT"
: "INTERNAL";
console.log(JSON.stringify({ at:"/skill/run", ip, skill, ok:false, code, msg }));
return json({ ok:false, code, message: msg }, code === "TIMEOUT" ? 504 : 500);
}
}
// ここまで来たら未定義ルート
return json({ ok:false, error: "Not Found" }, 404);
} catch (e) {
return json({ ok:false, code:"INTERNAL", message: String(e?.message || e) }, 500);
}
}
};
// ===== Helpers =====
function cors(){ return {
"Access-Control-Allow-Origin":"*",
"Access-Control-Allow-Methods":"GET,POST,OPTIONS",
"Access-Control-Allow-Headers":"Content-Type,Authorization"
};}
function json(obj,status=200){
return new Response(JSON.stringify(obj),{ status, headers:{ "content-type":"application/json", ...cors() }});
}
async function safeJson(req){ try{ return await req.json(); } catch{ return {}; } }
function clampInt(v,min,max){ const n=parseInt(String(v),10); return Math.max(min,Math.min(max,isNaN(n)?min:n)); }
// UUID v4(Workers 標準API利用、未実装環境でも自前生成)
function cryptoRandomUUID() {
if (crypto.randomUUID) return crypto.randomUUID();
const b = new Uint8Array(16);
crypto.getRandomValues(b);
b[6] = (b[6] & 0x0f) | 0x40; // version 4
b[8] = (b[8] & 0x3f) | 0x80; // variant
const hex = [...b].map(x => x.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
}
// BEST-EFFORT レート制限(単一インスタンス)
// 厳密制御が必要なら DO/KV or WAF を併用
function enforceRateLimit(env, bucket, max, windowMs = 60_000) {
env.__RL ||= new Map();
const now = Date.now();
let s = env.__RL.get(bucket);
if (!s || now - s.start >= windowMs) s = { start: now, count: 0 };
s.count++;
env.__RL.set(bucket, s);
if (s.count > max) {
return { ok:false, retryAfterMs: Math.max(0, windowMs - (now - s.start)) };
}
return { ok:true };
}
async function tfetch(url,init,ms=15000){
const c=new AbortController(); const id=setTimeout(()=>c.abort("timeout"),ms);
try{ return await fetch(url,{...init,signal:c.signal}); } finally{ clearTimeout(id); }
}
// ---- Embedding (robust: binding→HTTP→optional fake)
function modelList(env){
const m=[]; if (env.EMBED_MODEL) m.push(env.EMBED_MODEL);
m.push("@cf/baai/bge-base-ja-v1"); // JP安定
m.push("@cf/baai/bge-m3"); // 多言語
return m;
}
async function embed(env, text){
for (const m of modelList(env)){
const tries = [
() => env.AI?.run?.(m, { text:[text] }),
() => env.AI?.run?.(m, { input:[text] }),
() => env.AI?.run?.(m, { input_text:[text] }),
() => aiHttp(env, m, { text:[text] }),
() => aiHttp(env, m, { input:[text] }),
() => aiHttp(env, m, { input_text:[text] })
];
for (const t of tries){
try{
const out = await t();
const v = out?.data?.[0]?.embedding ?? out?.result?.data?.[0]?.embedding ?? out?.data?.[0];
if (Array.isArray(v)) return v;
}catch{}
}
}
if (String(env.ALLOW_FAKE_EMBED)==="1") return fakeEmbed(text, 768);
throw new Error("embedding failed");
}
async function embedMany(env, texts){
for (const m of modelList(env)){
const tries = [
() => env.AI?.run?.(m, { text:texts }),
() => env.AI?.run?.(m, { input:texts }),
() => env.AI?.run?.(m, { input_text:texts }),
() => aiHttp(env, m, { text:texts }),
() => aiHttp(env, m, { input:texts }),
() => aiHttp(env, m, { input_text:texts })
];
for (const t of tries){
try{
const out = await t();
const arr = out?.data?.map(d=>d.embedding) ?? out?.result?.data?.map(d=>d.embedding) ?? (Array.isArray(out?.data?.[0])?out.data:null);
if (Array.isArray(arr) && Array.isArray(arr[0])) return arr;
}catch{}
}
}
const seq=[]; for (const tx of texts) seq.push(await embed(env, tx)); return seq;
}
async function aiHttp(env, m, payload){
if (!env.CF_ACCOUNT_ID || !env.CF_API_TOKEN) throw new Error("http not configured");
const url=`https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/ai/run/${m}`;
const r=await tfetch(url,{ method:"POST", headers:{ "Authorization":`Bearer ${env.CF_API_TOKEN}`, "content-type":"application/json" }, body: JSON.stringify(payload) }, 20000);
const j=await r.json(); return j?.result ?? j;
}
function fakeEmbed(text, dim){
const out=new Array(dim); let h=2166136261;
for (let i=0;i<text.length;i++){ h^=text.charCodeAt(i); h=Math.imul(h,16777619); }
for (let i=0;i<dim;i++){ h^=(h>>>13); h=Math.imul(h,1274126177); out[i]=(h%2000)/1000-1; }
return out;
}
// ---- Qdrant (alias + header variants)
function baseUrl(env){ return String(env.QDRANT_URL||"").replace(/\/+$/,""); }
function qdrantHeaderVariants(key){
return [
{ "content-type":"application/json", "api-key": key },
{ "content-type":"application/json", "Authorization": `Api-Key ${key}` },
{ "content-type":"application/json", "Authorization": `Bearer ${key}` }
];
}
async function qfetch(env, endpoint, init={}){
const base=baseUrl(env); const key=env.QDRANT_API_KEY; let last="";
for (const h of qdrantHeaderVariants(key)){
try{
const r=await tfetch(`${base}${endpoint}`, { ...init, headers:{ ...(init.headers||{}), ...h } }, 15000);
if (r.status!==401 && r.status!==403) return r;
last=await r.text().catch(()=>`status=${r.status}`);
}catch(e){ last=String(e?.message||e); }
}
throw new Error(`qdrant auth failed: ${last}`);
}
async function ensureCollection(env, size){
const alias = env.QDRANT_COLLECTION; // 常用名
const real = `${alias}_d${size}`; // 実体は次元付き
let head = await qfetch(env, `/collections/${real}`, { method:"GET" });
if (!head.ok){
const created = await qfetch(env, `/collections/${real}`, {
method:"PUT", body: JSON.stringify({ vectors:{ size, distance:"Cosine" } })
});
if (!created.ok) throw new Error(`create collection failed: ${await created.text()}`);
}
await qfetch(env, `/collections/aliases`, { method:"POST", body: JSON.stringify({ actions:[{ delete_alias:{ alias_name: alias } }] }) }).catch(()=>{});
await qfetch(env, `/collections/aliases`, { method:"POST", body: JSON.stringify({ actions:[{ create_alias:{ collection_name: real, alias_name: alias } }] }) });
return true;
}
async function qdrantUpsert(env, points){
const r=await qfetch(env, `/collections/${env.QDRANT_COLLECTION}/points`, {
method:"PUT", body: JSON.stringify({ points })
});
if (!r.ok) throw new Error(`upsert failed: ${await r.text()}`);
}
async function qdrantSearch(env, vector, k){
const r=await qfetch(env, `/collections/${env.QDRANT_COLLECTION}/points/search`, {
method:"POST", body: JSON.stringify({ vector, limit:k, with_payload:true })
});
if (!r.ok) throw new Error(`search failed: ${await r.text()}`);
const j=await r.json(); return j?.result ?? [];
}
async function qdrantRetrieve(env, ids){
const r=await qfetch(env, `/collections/${env.QDRANT_COLLECTION}/points`, {
method:"POST", body: JSON.stringify({ ids, with_payload:true })
});
if (!r.ok) throw new Error(`retrieve failed: ${await r.text()}`);
const j=await r.json(); return j?.result ?? [];
}
// ---- PointId 正規化(整数/UUID へ統一)
const UUID_RE=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
async function normalizePointId(raw){
if (raw===undefined||raw===null) return cryptoRandomUUID();
if (typeof raw==="number") return raw;
if (typeof raw==="string"){
const s=raw.trim();
if (/^\d+$/.test(s)) return parseInt(s,10);
if (UUID_RE.test(s)) return s.toLowerCase();
// 任意文字列→決定的 UUID(SHA-256 の先頭16Bを v4 に整形)
const enc=new TextEncoder().encode(s);
const buf=await crypto.subtle.digest("SHA-256",enc);
const b=new Uint8Array(buf).slice(0,16);
b[6]=(b[6]&0x0f)|0x40; b[8]=(b[8]&0x3f)|0x80;
const hex=[...b].map(x=>x.toString(16).padStart(2,"0")).join("");
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
}
return cryptoRandomUUID();
}
まとめ
- “人の指示の曖昧さ”を補完しつつ、根拠でぶれない Custom GPT を、クラウド費 0円で構築できました。
- あとは
skill.runに小さな関数を足していけば、日々の作業を少しずつ自動化できます。 - Thinking モデルが使えない場合でも、Clarify-or-Assume + 0.1試作で“人間らしい”挙動を再現できます。
- フィードバックも歓迎していますので、より良く出来る機能・改善点など教えていただけると嬉しいです。
--