この記事でやること
ユーザーが日本語の自由文を投げてきて、それに対して決められた選択肢の1つを返す、という処理を LLM なしで実装します。
「ファイル名の大文字小文字は関係ありますか?」 → YES
「キャッシュが残ってるとか?」 → NO
「何時ごろに起きるんですか?」 → IRRELEVANT
用途はいろいろあります。
- クイズ・ゲームの判定(この記事の実例)
- FAQ チャットボットの一次振り分け
- 問い合わせフォームの自由記述からのカテゴリ推定
- LLM に投げる前のプリフィルタ(明らかなものは手前で捌く)
「今どき LLM に投げればいいのでは」と思われそうですが、決定性が要る場面では、ルールベースのほうが本質的に正しいことがあります。その理由も含めて書きます。
全コードは 100 行ちょっとの純粋な JavaScript で、依存ゼロ・ブラウザでも Node でも動きます。コピペして手元で試せる形で載せます。
なぜルールベースなのか
私が実際に必要になったのは、水平思考クイズ(ウミガメのスープ)の出題者ロジックでした。プレイヤーが自由に質問し、出題者は YES / NO / 関係ない / どちらとも言えない だけを返す形式です。
最初は LLM に真相を渡して判定させようとしたのですが、致命的な問題がありました。同じ趣旨の質問に、違う判定を返すことです。
推理ゲームでは、プレイヤーは過去の判定を積み上げて推論します。5回前に「キャッシュは関係ない」と言われたのに、言い換えて聞いたら「YES」が返る。これはモデルの精度の問題ではなく、確率的な生成と、決定性が要求される仕様のミスマッチです。
同じことは業務システムでも起きます。「同じ問い合わせ文なのに、日によって振り分け先が変わる」は、多くの場合バグとして扱われます。
| 観点 | LLM | ルール表 |
|---|---|---|
| 同じ入力への応答 | 揺れうる | 常に同一 |
| 未知の言い回し | 強い | 弱い(未ヒット) |
| レイテンシ | 数百ms〜数秒 | 1ms未満 |
| コスト | 従量 | ゼロ |
| デバッグ | プロンプトを祈る | どのルールが刺さったか特定できる |
| テスト | しにくい | 純関数なのでユニットテストが書ける |
というわけで、まずルール表で作り、未ヒットのときだけ LLM に回すという構成にしました。最後にその接続方法も書きます。
ステップ1:日本語の表記ゆれを正規化する
自由文が相手なので、includes() でそのまま比較すると穴だらけになります。
"キャッシュ" / "きゃっしゅ" / "キャッシュ" / "キャッシュ?" / "キャッシュ の 問題"
これらを1つに潰す関数を作ります。
/**
* 表記ゆれを吸収して比較用の文字列にする。
* 例: 「大文字・小文字 は 関係アル?」→「大文字小文字は関係ある」
*/
export function normalize(text) {
if (typeof text !== "string") return ""
return text
.normalize("NFKC")
.toLowerCase()
.replace(/[ァ-ヶ]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0x60))
.replace(/[\s ]/g, "")
.replace(/[、。,.,.!?!?「」『』【】()()[]\[\]・:;:;'"`]/g, "")
}
4行しかありませんが、それぞれ役割が違うので順に説明します。
normalize("NFKC") — Unicode 互換正規化
日本語入力で一番事故るのが全角・半角です。NFKC はこれをまとめて潰してくれます。
"CSS".normalize("NFKC") // → "CSS"
"キャッシュ".normalize("NFKC") // → "キャッシュ"
"123".normalize("NFKC") // → "123"
"㈱".normalize("NFKC") // → "(株)"
半角カナが全角カナに正規化されるので、この時点で「半角カナで入力された」ケースが消えます。
toLowerCase() — 英数の大小
CSS / css / Css を揃えます。ルール表に英語のキーワード(cache、null、dns など)を書きたいので必須です。
[ァ-ヶ] → ひらがな — カタカナ揺れ
ここが日本語特有のポイントです。
"キャッシュ".replace(/[ァ-ヶ]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0x60))
// → "きゃっしゅ"
Unicode 上でひらがな(U+3041〜U+3096)とカタカナ(U+30A1〜U+30F6)はちょうど 0x60 ずれて同じ順に並んでいるので、コードポイントを引くだけで変換できます。ライブラリは要りません。
これがあると、ルール表にはどちらか片方を書けばよくなります。「キャッシュ」と「きゃっしゅ」を両方書く必要がない、というのは 1,000 行規模のルール表になると効いてきます。
なお [ァ-ヶ] の範囲外なので、長音符 ー はそのまま残ります(ひらがな側でも同じ文字を使うため、変換不要)。ヷ〜ヺ のような一部の文字は対応するひらがながないため範囲から外しています。
空白・記号の除去
"大文字・小文字 は 関係アル?" // → "大文字小文字は関係ある"
これで判定を「部分文字列を含むか」だけに単純化できます。・ や ? の有無でマッチが外れる事故がなくなります。
副作用として文の区切りが失われますが、キーワードの出現だけを見る用途なら問題になりません。逆に係り受けを見たいなら、この設計は向いていません(そこは素直に形態素解析か LLM です)。
ステップ2:ルール表とマッチャ
ルール1件は「どれかを含めばマッチ(any)」「ただしこれを含むなら不成立(not)」「そのときの答え(a)」で表します。
const rules = [
{ any: ["大文字", "小文字", "case sensitive"], a: "YES", note: "そこが核心です" },
{ any: ["ファイル名", "命名", "つづり", "スペルミス"], a: "YES" },
{ any: ["キャッシュ"], not: ["ブラウザ"], a: "NO" },
{ any: ["権限", "パーミッション", "chmod", "403"], a: "NO" },
{ any: ["時間", "時刻", "タイミング", "曜日"], a: "IRRELEVANT" },
]
マッチャはこれだけです。
function ruleMatches(rule, normalizedQuestion) {
// not は1語でも含めば不成立(先に評価する)
if (rule.not?.some((word) => normalizedQuestion.includes(normalize(word)))) return false
// any はいずれか1語を含めばマッチ
return Boolean(rule.any?.some((word) => normalizedQuestion.includes(normalize(word))))
}
ルール表側の語も normalize() に通している点が重要です。入力だけを正規化して、ルール表を生のまま比較すると、ルール表に「キャッシュ」と書いた瞬間に永久にマッチしなくなります(入力側は「きゃっしゅ」に変換済みのため)。片側だけの正規化は、静かに壊れるので気づきにくいバグです。
判定本体は、上から順に見て最初にマッチしたものを返すだけです。
export function judgeByRules(question, rules) {
const normalized = normalize(question)
if (!normalized) return null
for (const rule of rules ?? []) {
if (ruleMatches(rule, normalized)) {
return { verdict: rule.a, note: rule.note }
}
}
return null // 未ヒット
}
戻り値が null になりうるのが設計上のポイントです。「わからない」を握りつぶさずに呼び出し側へ返すことで、あとから LLM フォールバックを差し込めます。
順序が仕様になる
for で先頭から回して即 return するので、配列の順序がそのまま優先順位です。運用ルールは1つだけ、「具体的な条件ほど先に書く」。
// ✕ 悪い例:広い条件が先にあると、後ろの具体的なルールに到達しない
[
{ any: ["ファイル"], a: "NO" },
{ any: ["ファイル名の大文字小文字"], a: "YES" }, // 永久に到達しない
]
// ○ 良い例
[
{ any: ["ファイル名の大文字小文字"], a: "YES" },
{ any: ["ファイル"], a: "NO" },
]
到達不能ルールは静かなバグになるので、規模が大きくなったら「後ろのルールが1度も刺さらない」を検出するテストを書いておくと安全です。
ステップ3:「答えが正しいか」は AND × OR の2階建てで見る
もう一段むずかしいのが、ユーザーの記述が正解といえるかの判定です。
たとえば正解が「参照している名前と実ファイル名で大文字小文字が食い違っていた」だとします。
- 「大文字小文字の話ですか?」 → これだけでは正解にしたくない
- 「ファイル名の大文字と小文字が違ってた」 → 正解にしたい
キーワード1個で正解にすると甘すぎ、完全一致にすると言い回しの数だけ落とす。そこで、概念ごとにグループを作り、グループ内は OR、グループ間は AND にします。
const solutionKeys = [
["大文字", "小文字", "case", "ケース"], // 概念A:大小の区別
["ファイル名", "名前", "命名", "パス", "つづり"], // 概念B:何の大小か
]
export function checkSolution(answer, solutionKeys) {
const normalized = normalize(answer)
const groups = solutionKeys ?? []
const matched = groups.filter((group) =>
group.some((word) => normalized.includes(normalize(word)))
).length
return {
correct: groups.length > 0 && matched === groups.length,
matched,
total: groups.length,
}
}
「必要な概念をすべて含んでいるか。ただし各概念の言い方は問わない」という判定になります。
このデータ構造の副産物が matched / total です。 全部は揃っていないが一部は当たっている、という中間状態が数値で取れるので、部分点フィードバックが追加実装ゼロで出せます。
const { correct, matched, total } = checkSolution(input, solutionKeys)
if (correct) {
console.log("正解!")
} else if (matched > 0) {
console.log(`惜しい。核心の ${matched}/${total} までは合っています。`)
} else {
console.log("違います。")
}
正誤の 0/1 しか返さない UI は、そこで離脱されます。「惜しい、2つのうち1つは合ってる」が出るだけで、もう一度考えてもらえる。判定ロジックの副産物がそのまま UX になる、気持ちのいいパターンです。
動かしてみる
ここまでを1ファイルにまとめたものです。node engine.mjs でそのまま動きます。
// engine.mjs
export function normalize(text) {
if (typeof text !== "string") return ""
return text
.normalize("NFKC")
.toLowerCase()
.replace(/[ァ-ヶ]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0x60))
.replace(/[\s ]/g, "")
.replace(/[、。,.,.!?!?「」『』【】()()[]\[\]・:;:;'"`]/g, "")
}
function ruleMatches(rule, q) {
if (rule.not?.some((w) => q.includes(normalize(w)))) return false
return Boolean(rule.any?.some((w) => q.includes(normalize(w))))
}
export function judgeByRules(question, rules) {
const q = normalize(question)
if (!q) return null
for (const rule of rules ?? []) {
if (ruleMatches(rule, q)) return { verdict: rule.a, note: rule.note }
}
return null
}
export function checkSolution(answer, groups = []) {
const a = normalize(answer)
const matched = groups.filter((g) => g.some((w) => a.includes(normalize(w)))).length
return { correct: groups.length > 0 && matched === groups.length, matched, total: groups.length }
}
/* ------------------------- 動作確認 ------------------------- */
const RULES = [
{ any: ["大文字", "小文字", "case sensitive"], a: "YES", note: "そこが核心です" },
{ any: ["ファイル名", "命名", "つづり"], a: "YES" },
{ any: ["os", "windows", "mac", "linux"], a: "YES" },
{ any: ["キャッシュ"], not: ["ブラウザ"], a: "NO" },
{ any: ["権限", "パーミッション", "403"], a: "NO" },
{ any: ["時間", "時刻", "タイミング", "曜日"], a: "IRRELEVANT" },
]
const SOLUTION = [
["大文字", "小文字", "case", "ケース"],
["ファイル名", "名前", "命名", "パス", "つづり"],
]
for (const q of [
"ファイル名の大文字・小文字は関係アル?", // 表記ゆれ全部入り
"キャッシュが残ってるとか?", // 半角カナ
"ブラウザキャッシュの可能性は?", // not で除外され、未ヒット
"時間帯は関係ありますか?", // 「時間」に刺さる
"何時ごろに起きるんですか?", // 同じ趣旨だが語が違い、未ヒット
"サーバーのメモリ不足ですか?", // どのルールにも当たらない
]) {
console.log(q, "→", judgeByRules(q, RULES) ?? "(未ヒット)")
}
console.log(checkSolution("大文字小文字の違い", SOLUTION))
// → { correct: false, matched: 1, total: 2 }
console.log(checkSolution("ファイル名の大文字と小文字が食い違ってた", SOLUTION))
// → { correct: true, matched: 2, total: 2 }
実行結果:
ファイル名の大文字・小文字は関係アル? → { verdict: 'YES', note: 'そこが核心です' }
キャッシュが残ってるとか? → { verdict: 'NO', note: undefined }
ブラウザキャッシュの可能性は? → (未ヒット)
時間帯は関係ありますか? → { verdict: 'IRRELEVANT', note: undefined }
何時ごろに起きるんですか? → (未ヒット)
サーバーのメモリ不足ですか? → (未ヒット)
{ correct: false, matched: 1, total: 2 }
{ correct: true, matched: 2, total: 2 }
表記ゆれ(全角・半角カナ・カタカナ・記号)は吸収できている一方で、4番目と5番目の差がこの方式の限界をそのまま示しています。「時間帯は関係ありますか?」は 時間 に刺さりますが、まったく同じ趣旨の「何時ごろに起きるんですか?」は語が違うので落ちます。何時 を any に足せば直りますが、この穴埋めは原理的に終わりません。だからこそ、未ヒットを null として返し、後段(LLM)に渡せる形にしておくことが効いてきます。
normalize / judgeByRules / checkSolution はすべて副作用のない純関数なので、そのままユニットテストが書けます。
import { describe, it } from "node:test"
import assert from "node:assert"
import { normalize, judgeByRules } from "./engine.mjs"
describe("normalize", () => {
it("表記ゆれを吸収する", () => {
assert.equal(normalize("キャッシュ"), normalize("きゃっしゅ"))
assert.equal(normalize("CSS?"), "css")
assert.equal(normalize("大文字・小文字 は 関係アル?"), "大文字小文字は関係ある")
})
})
describe("judgeByRules", () => {
const rules = [{ any: ["キャッシュ"], not: ["ブラウザ"], a: "NO" }]
it("any にマッチする", () => {
assert.equal(judgeByRules("きゃっしゅですか", rules)?.verdict, "NO")
})
it("not があれば不成立になる", () => {
assert.equal(judgeByRules("ブラウザキャッシュですか", rules), null)
})
})
「LLM に投げていたら書けなかったテスト」がここにあります。判定ロジックがテスト可能であることは、地味ですが大きな利点です。
ステップ4:未ヒットのときだけ LLM に回す
ルールベースの弱点は明確で、想定していない言い回しに無力です。そこを LLM で埋めます。ポイントは、LLM をデフォルトにせず、フォールバックに置くことです。
async function judge(question, puzzle) {
// 1. まずルール表(決定的・即時・無料)
const byRules = judgeByRules(question, puzzle.rules)
if (byRules) return { ...byRules, source: "rules" }
// 2. LLM が無効なら、素直に「わからない」を返す
if (!aiEnabled) {
return { verdict: "UNKNOWN", note: "別の言い方で聞いてみてください", source: "none" }
}
// 3. 未ヒットのときだけ LLM
const text = await callLLM(buildPrompt(question, puzzle))
return { verdict: parseVerdict(text) ?? "UNKNOWN", source: "ai" }
}
プロンプトにルール表そのものを渡す
ここが一番効いた工夫です。LLM に真相だけ渡すと、ルール表なら NO を返す質問に YES を返してしまうことがあります。ユーザーから見ると、判定基準が途中で変わったように見える。
なので、既存のルール表を判定基準の仕様書としてプロンプトに同梱します。
function buildPrompt(question, puzzle) {
const knownRules = (puzzle.rules ?? [])
.slice(0, 40)
.map((rule) => `- 「${rule.any.join(" / ")}」に関する質問 → ${rule.a}`)
.join("\n")
return [
"あなたは水平思考クイズの出題者です。以下の真相に照らして、判定だけを返してください。",
"",
"【真相(ユーザーには非公開)】", puzzle.truth, "",
"【既存の判定基準(一貫性を保つため必ず従うこと)】", knownRules, "",
"【ユーザーの質問】", question, "",
"【出力ルール】",
"- YES / NO / IRRELEVANT / UNCERTAIN のいずれか1語だけを出力する",
"- 真相と無関係な事柄への質問は IRRELEVANT",
"- 真相に情報がなく断定できない場合のみ UNCERTAIN",
"- 説明・理由・補足は一切書かない。真相を漏らさない",
].join("\n")
}
ルールベースの資産が、そのまま LLM の制約条件として再利用できるわけです。ルール表を書く手間が二重に回収できます。
出力は信用しきらない
1語だけと指示しても、YES、なぜなら… のように返ることはあります。パース側で吸収します。
function parseVerdict(text) {
const upper = (text ?? "").toUpperCase()
if (upper.includes("IRRELEVANT")) return "IRRELEVANT"
if (upper.includes("UNCERTAIN")) return "UNCERTAIN"
if (/\bYES\b/.test(upper)) return "YES"
if (/\bNO\b/.test(upper)) return "NO"
return null // 判別不能 → UNKNOWN 扱い
}
\b(単語境界)を付けているのは、NOT や KNOWN の一部を NO として拾わないためです。判別できなければ null を返し、呼び出し側で「わからない」に落とします。フォールバックの出力を無条件に信じないのは、フォールバックである以上妥当な距離感です。
実際に運用してみて
この仕組みで、68問・約 1,080 ルール(1問あたり平均16ルール)を書きました。LLM なしで完結させる代償はこの物量に全部乗っています。
ただ、やってみて意外だったのは、ルールを書く作業がコンテンツの品質を上げたことです。「この問題に対して、人は何を聞いてくるか」を列挙する作業は、そのまま問題設計のレビューでした。想定質問が10個しか出ない問題は、だいたい問題として面白くない。ルールを書きながら問題文の誘導を直す、という往復が何度も発生しました。LLM に丸投げしていたら、この往復は起きませんでした。
完成品は ディレクターが作ったウミガメのスープ で遊べます(登録不要・ブラウザ完結、AI はデフォルト OFF)。エンジニア編は「本番だけ画像が出ない」「金曜だけデプロイが落ちる」「調べると消えるバグ」など、実務で踏むやつを問題にしてあります。
まとめ
- 自由文入力=LLM 必須、ではない。決定性が要る場面ではルール表のほうが正しい
- 日本語の表記ゆれは NFKC + カタカナ→ひらがな(
-0x60)+ 記号除去でかなり潰せる -
入力とルール表の両側を同じ
normalize()に通す。片側だけだと静かに壊れる - ルールは配列の順序が優先順位。具体的な条件を先に書く
- 正誤判定は グループ間 AND × グループ内 OR。中間状態
matched / totalが部分点 UX になる - 判定関数を純関数にしておくと、ユニットテストが書けて
null(未ヒット)で LLM に繋げられる - LLM をフォールバックにすると、ルール表を仕様書としてプロンプトに渡して一貫性を縛れる
「まず LLM なしのベースラインを作り、埋まらない隙間だけ LLM に渡す」。この順番で作ると、コスト・レイテンシ・テスト可能性が全部ついてきます。
SEOスコアチェックツール: SEO_CHECK — RINIAディレクターツール。
Web制作・SEO関連の技術情報サイト: CodeQuest.work