TL;DR
- カクテルレシピ検索で「Google風の自然なクエリ」でJSON検索をしたかった
- 自作にもチャレンジ(PEG.js、search-query-parser + JSONPath-Plus)したが、実装の複雑さで挫折
- AIと対話しながらChevrotainでパーサー実装→「CIR(共通中間表現)」という設計に到達
- 副次的にバックエンド非依存も実現(SQLiteやMongoDBの検索もできる)
- cirqueryとしてGitHubとnpm公開
- cirquery JSON Playgroundで今すぐ試せる
この記事では、OSS cirquery の実装を通じて学んだ、クエリパーサーとクエリの中間表現について共有します。
当初の目的は「バックエンド非依存」ではなく、あくまで「自然なクエリ表現の実現」でしたが、その過程で**中間表現(CIR)**という概念が必然的に生まれ、結果的にバックエンド非依存も実現できました。
1. きっかけ:カクテルレシピ検索での課題
1年前の2024年8月、私はカクテルレシピを管理するWebアプリを開発していました。 (JSONPathを使ってハマった件)
ユーザーがGoogle風の自然なクエリでJSONデータを検索できるようにしたいと思って検索機能に注力していました。
当時は、例えば下記のようなクエリで検索できるようにしていました。
// 原料にジンを含むアルコール度数20度未満のカクテル
ingredients.name:"ジン" AND alcohol_content<20
しかし、もっと複雑なクエリに対応しようとすると途端に手が付けられなくなってしまいました。
例えば下記のようなクエリでは、ANDとOR、かっこの中の優先順位など、どの記号をどのように扱うかを細かく定義する必要があり、入力されたクエリを解析するために複雑な処理が必要で、実装することがかないませんでした。
ingredients.name:"ジン" AND alcohol_content<20 OR (category:"アフターディナー" AND instructions:"シェイカー")
また、そもそもJSONを取り扱うのも簡単ではありませんでした。
当時は下記のようなJSONのデータを検索の対象としていましたが、階層の深いプロパティを指定するのも意外と難しいことがわかりました。
[
{
"id": 1,
"name": "マティーニ",
"ingredients": [
{"ingredient_id": 101, "name": "ジン", "amount": "60ml"},
{"ingredient_id": 102, "name": "ドライベルモット", "amount": "10ml"}
],
"alcohol_content":0,
"instructions": "氷と材料をミキシンググラスで混ぜ、カクテルグラスに注ぐ。",
"category": "クラシック",
"image": "/images/recipes/550e8400-e29b-41d4-a716-446655440000.jpg"
},
{
"id": 2,
"name": "グラスホッパー",
"ingredients": [
{"ingredient_id": 103, "name": "クリーム", "amount": "30ml"},
{"ingredient_id": 104, "name": "メンソールリキュール", "amount": "30ml"},
{"ingredient_id": 105, "name": "カカオリキュール", "amount": "30ml"}
],
"alcohol_content":0,
"instructions": "全ての材料をシェイカーに入れ、よく振ってカクテルグラスに注ぐ。",
"category": "アフターディナー",
"image": "/images/recipes/6ba7b810-9dad-11d1-80b4-00c04fd430c8.jpg"
}
]
私が検索システムに求めていた要件は以下の通りです:
- ✅ Google風の直感的な構文(
field:"value" AND other:>10) - ✅ 複雑な論理式(括弧、AND/OR/NOT)
- ✅ ネストしたJSON構造への対応
- ✅ 配列要素の検索(
ingredients.name:"ジン") - ✅ 数値比較演算子(
<,>,<=,>=,=,!=)
検索機能を実装できなかった事情を振り返りながら、今回自作のクエリパーサーであるcirqueryについて説明していきたいと思います。
2. 既存ライブラリの調査と自作の試み
最初は「検索ライブラリはたくさんあるだろう」と考え、まずJavaScript/TypeScriptで利用できるものを調査しました。
検索システムは、一般的に大きく2つの役割に分かれています。
-
クエリパース(Query Parse)
ユーザーが入力したクエリ文字列(例:ingredients.name:"ジン")を解析し、その意図をプログラムが扱いやすい形式(例えば、AST=抽象構文木)に変換する役割です。 -
検索・評価(Search/Evaluation)
解析されたクエリの条件を基に、データベースやJSONデータの中から該当する(=評価)情報を実際に探し出す役割です。
多くのライブラリでは、この2つの役割が分かれていたり、あるいは一体として設計されていたりします。この分担や実装方法は、ライブラリの用途や性能要件、拡張性の要求によって異なり、それぞれの「個性」となっています。
ここで重要な背景として、私が目指した 「Google(風)の検索クエリ」には、実は標準仕様が存在しません。
その中で、ElasticsearchやSolrなど多くの検索エンジンが採用し、事実上のデファクトスタンダードとなっているのが、Apache Luceneのクエリ構文です。
しかし、このLuceneはJavaで開発されたライブラリであり、WebのJavaScript環境で直接利用することはできません。JavaScript向けにLuceneの構文をパースするライブラリ(lucene-queryparserなど)も存在しますが、それらはあくまでクエリ文字列をASTに変換するだけで、Lucene本体が持つ強力な検索エンジン機能(検索・評価の役割)までを移植したものではありませんでした。
さらに、それらのライブラリが生成するASTは、JSON特有の深いネスト構造を検索する目的では作られていませんでした。
Luceneの標準構文はfield:valueのようなフラットな構造を前提としており、ingredients.nameといったパスを階層として解釈してくれません。そのため、得られるASTはそのままではJSON検索に利用できませんでした。
このような背景から、Web環境で「Google風クエリ」による柔軟な検索を実現しようとするにも、既存のJavaScriptライブラリは特定用途に特化しているものが多く、汎用的に使えるツールは非常に限られている、という現実に直面しました。
以下に、今回の要件(自然なクエリでJSONを検索する)に照らして調査した、代表的な既存ライブラリの調査結果と、実際に自作を試みて直面した課題をまとめます。
2.1 既存ライブラリの評価:何が足りなかったのか
まず、「クエリパース」と「検索・評価」が一体化したライブラリを調査しました。しかし、どれも今回の要件にはフィットしませんでした。
FlexSearch:構造化クエリが書けない
FlexSearchは高速な全文検索が魅力ですが、構造化クエリ(例:field:value AND other:>10)に対応しておらず、数値比較もできませんでした。
sift.js:クエリが直感的ではない
sift.jsはMongoDB風のクエリで配列をフィルタリングできますが、そのクエリ構文は一般ユーザーには直感的ではありません。
例えば、ingredients.name:"ジン" AND alcohol_content<20という検索は、以下のように書く必要があります。
{
$and: [
{ ingredients: { $elemMatch: { name: 'ジン' } } },
{ alcohol_content: { $lt: 20 } }
]
}
これを検索ボックスに直接入力させるのは現実的ではありません。
JMESPath:目的が違う
JMESPathは、JSONのデータの抽出・変形に強みを持つライブラリです。JSONのフィルタリングも可能ですが、その構文はプログラミング言語に近く、一般ユーザーには直感的とは言い難いクエリ構文となっています。
例えば、ingredients.name:"ジン" AND alcohol_content<20という検索は、以下のように書く必要があります。
[?contains(ingredients[].name, 'ジン') && alcohol_content < `20`]
search-input-query: JSON非対応
search-input-queryはGoogle風のクエリをパースできます。
しかし、このライブラリはSQLへの変換に特化しており、私が求めていた「任意のJavaScriptオブジェクト(JSON)を検索する」という汎用的な評価機能は提供されていませんでした。
2.2 自作の試み:なぜ「無理」だったのか
既存の完成品がないなら、「クエリパース」と「検索・評価」を組み合わせて自作しようと試みましたが、これもまた茨の道でした。
Try 1:search-query-parser × JSONPath-Plus
「クエリパース」の入り口をsearch-query-parserに、そして「検索・評価」の中核となるデータアクセスをJSONPath-Plusに任せる構成です。
しかし、search-query-parserは単純なキー・バリューしか扱えず、複雑な論理式に対応していませんでした。その結果、パース結果を解釈して自力でAND/ORの評価順を組み立て、JSONPath-PlusでJSONの値を取得して比較する部分が150行を超える文字列組み立て地獄となり、簡単なAND処理を実装するのが限界でした。
// こんな地獄のコードを書いていた…
function buildJsonPathQuery(parsed) {
let conditions = [];
if (key == "alcohol_content") {
// 大小比較の処理
} else {
if (key == 'ingredients') {
// ワイルドカード処理、正規表現のエスケープ…
// 延々と続くif/else…
}
}
// さらに100行以上…
}
Tyr 2:PEG.js
次に、PEG.jsで独自のパーサーを実装しようとしました。ASTの生成はできましたが、そのASTを解釈してJSONのネスト構造や配列を評価するロジックを自力で書く必要があり、結局は同じ複雑さの問題に直面しました。
2.3 調査と試行錯誤の結論:作るしかなかった
これまでの調査と実装の試みをまとめると、下記のようになります。
- 一体型ライブラリは、構文が直感的でないか、機能が不足していた。
- 類似プロジェクトは、用途がSQLに特化しており、汎用的なJSON検索には使えなかった。
- 部品の組み合わせによる自作は、実装が複雑になりすぎて破綻した。
比較表
| ライブラリ | 自然な検索クエリ | ネスト対応 | 論理演算 | 検索対象 |
|---|---|---|---|---|
| FlexSearch | △ | △ | ❌ | JSON |
| sift.js | ❌(Mongo文法) | ✅ | ✅ | JSON |
| JMESPath | △ | ✅ | ✅ | JSON |
| search-input-query | ✅ | ✅ | ✅ | SQL |
| 自作(※) | ✅ | △ | ❌(限定的) | JSON |
| cirquery | ✅ | ✅ | ✅ | JSON + 拡張可能 |
(※ search-query-parser × JSONPath-Plus の組み合わせ)
この結果から、「Google風の自然なクエリで、ネストしたJSONを柔軟に検索できる、汎用的なJavaScriptライブラリは存在しない。そして、既存のライブラリを組み合わせても実現が難しい」という結論に至りました。
なぜ、このような「ありそうで無い」状況が生まれているのでしょうか。私はその理由を様々な生成AIに調査させ、以下のように結論づけました。
-
エコシステムの成熟と専門化
ElasticsearchやSolrのような大規模検索システムは、すでに独自の強力なクエリDSL(ドメイン固有言語)を持っており、それを使う文化が成熟しています。開発者はその「方言」を学ぶことで、高度な検索機能を引き出せるため、それらをわざわざ抽象化して、ブラウザ上の単純なJSON検索に使う動機が薄いのです。 -
表現力と最適化のトレードオフ
MongoDB、Elasticsearch、SQLなど、各データベースは 「この書き方なら速い」 という、それぞれに最適化されたクエリの仕組みを持っています。もし、すべてに対応できる「最大公約数」的な汎用クエリ構文を作ろうとすると、各データベース固有のパフォーマンス上の長所が消えてしまい、結果として 「どこでも動くけど、どこでも遅い」 という、魅力のないものになりがちです。 -
実装負荷の高さ
Google風のシンプルなクエリ構文の裏側では、複雑な現実が待っています。ネスト構造のパス解決、配列に対する量指定子(any/all)、数値と文字列の厳密な型比較などを安全かつ網羅的に扱う汎用のクエリパーサーと評価器をゼロから作るのは、趣味のOSS開発としては重すぎる、非常に実装コストが高い挑戦です。
要は「プロには不要、趣味には重い」という絶妙な隙間だったわけです。AIとの対話を通じて、この未開拓な領域には挑戦する価値があると感じたことが、cirquery開発を決断する直接の動機となりました。
3. cirquery開発の流れ:ChevrotainによるパースとCIRへの必然的到達
ここからは、実際にcirqueryをどのように開発していったのか、その過程でなぜ**CIR(共通中間表現)**という概念が必然的に生まれたのかを解説します。
3.1 開発の経緯
cirqueryの企画・設計は、GPT、Claude、Gemini、Grokといった各種大規模言語モデル(LLM)との対話と調査を繰り返しながら進めました。
開発においては要件定義からコーディング、githubやnpmの公開に至るまで、LLMを使い倒して「AI駆動開発」を実施しています。
3.2 クエリパーサの選定:なぜChevrotainを選んだか
検索システムを構成する2つの要素、「クエリパース」と「検索・評価」をどう実現するか。特に「クエリパース」のツール選定において、LLMとの対話は大きなヒントを与えてくれました。その結果、型安全かつ高速なパーサービルディングツールキットであるChevrotainが最適だと判断し、採用に至りました。
当初はPEG.jsも検討しましたが、以下の点でChevrotainに優位性がありました。
| 観点 | PEG.js | Chevrotain |
|---|---|---|
| パース速度 | 普通 | 高速 |
| TypeScript対応 | 弱い | ネイティブ対応 |
| エラー回復 | 基本的 | 強力 |
| AST変換 | 手動実装 | AST巡回ツール |
PEG.jsはパーサーを生成することに特化しており、その後の評価ロジックはすべて自分で書く必要があります。これは設計思想の違いであり、PEG.jsが劣っているわけではありません。
しかしcirqueryでは、パース後の「ASTからCIRへの変換」までをクリーンに実装したかったため、Chevrotainを選択しました。
ここで言う「クリーンに実装する」とは、「役割分担を明確にし、コードの見通しを良く、将来の変更を容易にする」という意味です。
PEG.jsでは、パースの文法ルールの中にASTを構築したり変換したりする処理を埋め込むため、パースのロジックと変換のロジックが一体化し、「ごちゃ混ぜ」になりがちです。
一方、Chevrotainは「AST巡回ツール」(専門用語でVisitorパターン)という優れた仕組みを提供しています。これにより、「パース処理」と「ASTの変換処理」を完全に分離できます。まずパースに専念して純粋なASTを作り、その後で、完成したASTをこの「巡回ツール」で見て回りながら、部品の種類ごとに変換処理をスッキリと整理して書けるのです。
「巡回ツール」では、例えば下記のように、「ORの部品が来たな、じゃあこの処理をしよう」「ANDの部品が来たから、次はこの処理だ」というように、部品(ノード)の種類ごとに、行うべき変換処理を明確に分けて書くことができます。
// ChevrotainのAST巡回ツール(イメージ)
class AstToCirTransformer extends AstVisitor {
// ANDの部品が来たら、このメソッドが呼ばれる
visitAndNode(node) {
// ANDノードをCIRのAndノードに変換する処理
}
// ORの部品が来たら、このメソッドが呼ばれる
visitOrNode(node) {
// ORノードをCIRのOrノードに変換する処理
}
// ... 他の部品(ノード)も同様に定義
}
この「関心の分離」こそが、cirqueryのようにASTに対して複雑な変換を行いたい場合に、コードの保守性や拡張性を劇的に高めてくれる強力な武器となりました。
そして、この選択は、JSONのネスト構造を適切に処理する上で決定的に重要でした。
cirqueryのパーサーは、ingredients.nameのようなドット記法を「ネストしたパス」として認識し、その情報をASTに含めるように独自に設計しました。これは、既存のLucene系パーサーにはない重要な機能です。
この独自設計のASTを、「AST巡回ツール」が体系的に処理することで、cirqueryはネスト構造を正しく解釈できるのです。例えば、「Pathノードが来たら、ネスト構造として処理する」といった専用のロジックを、クリーンに実装することが可能になります。
3.3 CIRが生まれた理由:ASTの限界と正規化の必要性
Chevrotainによるクエリパースの結果、**AST(抽象構文木)**が生成されます。ASTは、入力されたクエリの構造をそのまま木構造で表現したものです。
例えば、(NOT a:"1" AND b:"2") OR c:"3" というクエリは、以下のようなASTになります。
// ASTの例(括弧や演算子の優先順位がそのまま反映される)
{
type: "OR",
left: {
type: "AND",
left: { type: "NOT", expr: { field: "a", value: "1" } },
right: { field: "b", value: "2" }
},
right: { field: "c", value: "3" }
}
このASTをそのまま「検索・評価」に使うには、いくつかの課題があります。
-
評価が複雑になる:
ORやANDのノードを再帰的にたどりながら、NOTの反転処理や括弧の優先順位を考慮して評価ロジックを書くのは非常に複雑です。 - 最適化が困難: このままでは、クエリの最適化(例えば、不要な条件の削除など)を行うのが難しいです。
この課題を解決するため、ASTをより機械的に処理しやすい形式に変換する正規化 (Normalization) という工程が必要になります。
正規化の具体的な処理
- 括弧の展開: 演算子の優先順位を解決します。
-
NOTの押し下げ: ド・モルガンの法則を使い、NOT (A OR B)を(NOT A) AND (NOT B)に変換するなどして、NOTを個々の条件に適用させます。 -
論理式の平坦化:
A AND B AND Cのように、同じ論理演算子が続く場合に、木構造をフラットなリスト構造に変換します。
この正規化処理の結果として生まれたのが、CIR(共通中間表現:Common Intermediate Representation) です。
つまり、当初からバックエンド非依存を目指していたわけではなく、「ASTを効率的に評価するためには、正規化が必要だった。その結果として生まれたCIRが、たまたまバックエンド非依存という特性を持っていた」というのが真実です。CIRは「設計思想」というより「実装の必然」から生まれた産物でした。(なお、AIは最初からCIRを導入するのが良いと言っていましたが、実装してようやく理解できました。)
3.4 CIRの具体例
ingredients.name:"ジン" AND category:"クラシック" というクエリはCIRでは下記のように表現されます。
{
"type": "And",
"children": [
{
"type": "Quantified",
"quantifier": "any",
"path": { "segments": ["ingredients"] },
"predicate": {
"type": "Text",
"path": { "segments": ["name"] },
"op": "contains",
"value": { "value": "ジン" }
}
},
{
"type": "Text",
"path": { "segments": ["category"] },
"op": "contains",
"value": { "value": "クラシック" }
}
]
}
-
(A AND B)という構造が、"type": "And"とchildren配列というフラットな構造に変換されています。 -
ingredients.nameというネストしたクエリが、"type": "Quantified"(量化)という概念で表現されています。これは「ingredients配列の**いずれか(any)**の要素が、nameに"ジン"を含む」という意味です。
この構造は、JSON、MongoDB、SQLといった特定のバックエンドに依存せず、純粋に「クエリの意味」だけを表現しています。
3.5 Elasticsearchも同じ道をたどった
後から知ったのですが、Elasticsearchもバージョン5.0で同様の設計変更を行っています。
-
Before (v4.x):
JSONリクエスト → 直接Luceneクエリに変換 -
After (v5.0+):
JSONリクエスト → QueryBuilder(中間表現) → Luceneクエリ
中間表現を導入することで、クエリの最適化やバリデーションが容易になり、将来的な拡張性も高まります。cirqueryがCIRに行き着いたのは、大規模システムにも通じる合理的な帰結であったことが分かります。
3.6 CIR化の副次効果:拡張性
本来の目的は「自然なクエリでJSONを検索する」ことでしたが、CIRが生まれたことで、思わぬ副次効果がありました。
- バックエンド非依存: CIRから各バックエンド(JavaScript配列、SQL、MongoDBなど)のネイティブなクエリを生成する「アダプター」を実装すれば、同じ検索クエリを使い回せます。
- テスト容易性: クエリ文字列のパース結果であるCIRが正しいかどうかをテストすることで、ロジックの堅牢性を担保できます。
- 拡張性: 新しい検索演算子を追加したい場合も、パーサーとCIRの定義、そしてアダプターを修正するだけで対応できます。
4. 実装の技術的チャレンジ
cirqueryの実装は、ほとんどすべてAI駆動開発ではあるのですが、その中でも以下の4点は大きな挑戦でした。
1. 配列の量化(any/all/none)の扱い
ingredients.name:"ジン" というクエリは、暗黙的に「ingredients配列のいずれかの要素のnameが"ジン"に一致する」という意味です。この「いずれか」という量化の概念を、CIRで明示的に表現し、評価ロジックに反映させる必要がありました。
2. 論理演算の正規化
前述の通り、NOTの押し下げ(ド・モルガンの法則)や括弧の優先順位の解決など、複雑な論理式を正しく評価するための正規化ロジックの実装は、このライブラリの心臓部であり、最も注意を払った点です。クエリを構成する部品を増やすほどに、優先順位のロジックを検討しなおしました。
3. 型の推論
alcohol_content=20(数値)とalcohol_content="20"(文字列)をどう区別し、比較演算子(<, >)をどう解釈させるか。クエリの利便性と厳密性のバランスを取るのに苦労しました。
4. CIRから評価器への変換
CIRは、あくまで「クエリの意味」を表現した設計図にすぎません。この設計図を元に、実際にJavaScriptの配列をフィルタリングできる**実行可能な関数(述語関数)**を生成する必要があります。この変換処理は、ASTの正規化とはまた別の、以下のような複雑さを伴います。
-
安全なパス探索:
a.b.cのような深い階層の値を、途中のプロパティが存在しなくてもエラーなく安全に取得するロジック。 -
量化ロジックの実装: CIRの
any(いずれか)をArray.prototype.someに、all(すべて)をArray.prototype.everyに対応させるなど、配列検索のコアロジックの実装。 -
演算子のマッピング: CIRの
containsやgtといった抽象的な演算子を、JavaScriptの.includes()や>といった具体的な処理に正しく変換すること。 -
オプションの適用:
ignoreCase: trueのようなオプションが指定された場合に、大文字・小文字を区別しない比較を正確に行うこと。
この評価器の品質が、cirqueryの検索精度に直結するため、CIRの実効性を示すための、もう一つの大きな技術的チャレンジでした。
5. 実際のコード例とデモ
5.1 インストール
npm install cirquery
5.2 基本的な使い方
import { parse, normalize, buildPredicate } from 'cirquery';
const data = [
{
id: 1,
name: "マティーニ",
ingredients: [{ name: "ジン", amount: "60ml" }],
category: "クラシック"
},
// ...
];
const query = 'ingredients.name:"ジン" AND category:"クラシック"';
const { ast } = parse(query);
const cir = normalize(ast);
const predicate = buildPredicate(cir, { ignoreCase: true });
const results = data.filter(predicate);
console.log(results); // => [{ name: "マティーニ", ... }]
5.3 JSON Playgroundで今すぐ試せる
実際に動かせるデモサイトを用意しました。ぜひ、お手元のJSONデータで試してみてください。
👉 https://cirquery.github.io/cirquery/
できること:
- 自分のJSONファイルをドラッグ&ドロップ
- クエリを入力すると、リアルタイムでフィルタリング
- 生成されたCIRの中身も見られる(この記事を読んだ後だと、より面白いはずです!)
試してほしいクエリ例:
ingredients.name:"gin" AND abv_estimate<20
description:"sweet" OR tasting_notes:"herbal"
(flavor_profile.sweetness>2 AND tasting_notes:"aroma") AND ingredients.name:"vodka"
6. 制限事項と今後の展望
6.1 フィルタリング専用
現状、cirqueryは「フィルタリング(条件に合うものを絞り込む)」に特化しています。
例えば、「名前とカテゴリーだけ取り出す」といった**フィールドの選択(射影)**はできません。
// ❌ こういうこと(特定フィールドだけ取り出す)はできない
// SELECT name, category FROM cocktails WHERE ...
// ✅ できるのはフィルタリングだけ
const filtered = cocktails.filter(predicate);
// ✅ その後で.map()すればOK
const names = filtered.map(c => ({ name: c.name, category: c.category }));
理由: まずはフィルタリング機能に集中することで、実装をシンプルに保っています。
6.2 データベースアダプターは開発中
- ✅ JavaScript
Array.filter()アダプター(実装済み) - 🚧 SQLiteアダプター(プロトタイプ実装済み)
- 📅 MongoDBアダプター(予定)
- 📅 Elasticsearchアダプター(予定)
7. まとめ
7.1 学んだこと
-
自然な検索クエリの実現は、思った以上に複雑
既存ライブラリは「自然さ」よりもそれぞれの「機能性」を優先しており、自作も簡単ではありませんでした。 -
CIRは「設計思想」ではなく「実装の必然」
評価しやすい形を追求した結果、ASTの正規化は避けられず、その過程でCIRが生まれました。これはElasticsearchがたどった道と同じでした。 -
副次的に得られた大きなメリット
結果として得られたCIRのバックエンド非依存という特性は、今後のライブラリの拡張性に大きな可能性を与えてくれました。実際にSQLiteのアダプターを試作して効果を確認できています。
7.2 フィードバックを募集しています!
cirqueryはまだ始まったばかりのプロジェクトです。「こういう機能が欲しい」「ここが使いにくい」など、ぜひGitHubのIssuesで教えてください!
👉 https://github.com/cirquery/cirquery