PostgreSQL『syntax error in tsquery』の原因と対処(全文検索/tsquery)

PostgreSQL『syntax error in tsquery』の原因と対処(全文検索/tsquery)

to_tsquery/phraseto_tsquery/websearch_to_tsquery 等で作る tsquery の構文が不正なときに出る。代表パターン(演算子の並び・未対応の記号・不一致なカッコ・ストップワードで空オペランド化)を網羅し、最短復旧、堅牢なユーザー入力処理、デバッグ手順、再発防止の設計指針をまとめた。

エラーの意味と代表メッセージ

ERROR:  syntax error in tsquery: "foo & & bar"
ERROR:  syntax error in tsquery: "foo |"
ERROR:  syntax error in tsquery: "(foo & bar"
ERROR:  syntax error in tsquery: "the & and"

・tsquery の構文(演算子/トークン/括弧)が壊れている

・左/右オペランドの欠落、未対応文字、括弧不一致、辞書処理後に“空”になってしまう等が主因

まず把握:tsquery の基本文法と許可トークン

-- 演算子の例
a & b            -- AND
a | b            -- OR
!a               -- NOT
a <-> b          -- 隣接(語順/距離演算子:a と b が隣接)
a:*              -- prefix マッチ("a..."で始まる語)
(a & b) | c      -- 括弧で優先順位制御

-- 入力関数の違い(安全性と表現力)
to_tsquery('simple', 'a & b')          -- 厳格な構文を要求(主に機械生成向け)
plainto_tsquery('simple', 'a b')       -- 単語列を安全に AND 連結
phraseto_tsquery('simple', 'a b')      -- 連語(a <-> b <-> ...)に展開
websearch_to_tsquery('simple', 'a "b c" -d OR e') -- Web検索風構文を安全に解釈

典型的な発生条件(チェックリスト)

□ "&" や "|" が連続/末尾/先頭にある(例: "a & & b", "a |")
□ 未対応記号をそのまま渡している(例: "C++ & C#" を to_tsquery に直渡し)
□ 括弧の不一致("(" と ")" の数/位置が合わない)
□ 演算子の前後どちらかが空(ストップワード除去後に "the & and" など)
□ to_tsquery にユーザー文字列を直結(意図せず演算子扱い)
□ ダブルクオートで「フレーズ検索」を期待(to_tsquery は理解しない)
□ prefix の :* の付け方が誤り("a:*:*" 等)

最短復旧:ユーザー入力は websearch_to_tsquery か plainto_tsquery に切替

-- ユーザーの自由入力はまずこちら(Web検索風を安全解釈)
SELECT websearch_to_tsquery('simple', :q);

-- “語の AND 列”として扱えばよいなら
SELECT plainto_tsquery('simple', :q);

-- 連語を重視するなら
SELECT phraseto_tsquery('simple', :q);

・to_tsquery は厳格構文のため、人間が打つ文字列をそのまま渡すのは危険

・機械的に a & b & c… を組み立てる用途でのみ to_tsquery を使う

再現と修正のミニ例(NG→OK)

-- 1) 演算子が連続
SELECT to_tsquery('simple', 'foo & & bar');   -- NG: syntax error
SELECT to_tsquery('simple', 'foo & bar');     -- OK

-- 2) 末尾が演算子
SELECT to_tsquery('simple', 'foo |');         -- NG
SELECT to_tsquery('simple', 'foo | bar');     -- OK

-- 3) 括弧不一致
SELECT to_tsquery('simple', '(foo & bar');    -- NG
SELECT to_tsquery('simple', '(foo & bar)');   -- OK

-- 4) ストップワードで空オペランド化(英語辞書で "the","and" は除去)
SELECT to_tsquery('english', 'the & and');    -- NG
SELECT plainto_tsquery('english', 'the and'); -- OK: 空になったら ' を返すことも

未対応記号の扱い(C++ / C# / e-mailなど)

-- NG: 記号を含む語を to_tsquery に直渡し
SELECT to_tsquery('simple', 'C++ & C#');  -- NG: syntax error の典型

-- OK: ユーザー入力は websearch_to_tsquery へ
SELECT websearch_to_tsquery('simple', 'C++ AND "C#"');

-- どうしても to_tsquery を使う場合は正規化(記号除去/置換)してから
SELECT to_tsquery('simple', 'c & c');     -- 例:正規化後

・辞書/構成により “+” や “#” は分割/除去されることが多い

・「正規化→to_tsquery」より「そのまま→websearch_to_tsquery」が堅牢

フレーズ検索は <-> か phraseto_tsquery を使う

-- NG: to_tsquery は "foo bar"(ダブルクオート)をフレーズと解釈しない
SELECT to_tsquery('simple', '"foo bar"');     -- NG: syntax error(または期待と違う)

-- OK: 距離演算子 <->(隣接)で表現
SELECT to_tsquery('simple', 'foo <-> bar');

-- 連語は phraseto_tsquery が簡単
SELECT phraseto_tsquery('simple', 'foo bar'); -- = 'foo' <-> 'bar'

辞書/ストップワードの影響で空になったときの扱い

-- “英語辞書”で the/and は除去 → 演算子だけが残ると構文エラー
SELECT to_tsquery('english', 'the & report');   -- the が消えても OK(右が残る)
SELECT to_tsquery('english', 'the & and');      -- 両方消えて NG

-- 事前ガード:空なら全文検索をスキップ
WITH q AS (SELECT websearch_to_tsquery('english', :q) AS tsq)
SELECT *
FROM docs, q
WHERE q.tsq <> ''::tsquery      -- tsq が空('')なら検索条件を外す など

安全な入力処理レシピ(SQLのみで完結)

-- 1) ユーザー入力を Web 検索構文で解釈
WITH q AS (
  SELECT websearch_to_tsquery('simple', :q) AS tsq
)
SELECT id, title, ts_rank_cd(to_tsvector('simple', body), tsq) AS rank
FROM articles, q
WHERE to_tsvector('simple', body) @@ tsq
ORDER BY rank DESC
LIMIT 20;

-- 2) 空/危険入力のガード(tsq = '' を弾く)
...
WHERE q.tsq <> ''::tsquery

・アプリ側で“予約記号”を消すより、websearch_to_tsquery で自然に解釈させる方が事故が少ない

to_tsquery を使う「機械生成」パターン(構文を自前で保証)

-- 例:AND 連結だけを機械的に構築
WITH terms AS (SELECT unnest(ARRAY['postgresql','tsquery','syntax']) AS t)
SELECT to_tsquery('simple',
       string_agg(quote_ident(t) || ':*', ' & ' ORDER BY t)
       -- ↑ prefix 検索を付けたい場合の一例(:*は語に付ける)
) AS tsq;

・terms が空なら ”::tsquery にする処理を入れる

・ユーザー入力をこの経路に通す場合はトークン化/正規化済みであること

デバッグ手順:どこで壊れているかを特定

-- 1) その辞書でのトークン化を観察
SELECT ts_debug('english', :text);      -- どの語が除去/保持されたか

-- 2) 生成クエリを可視化
SELECT to_tsvector('english', :text);
SELECT websearch_to_tsquery('english', :q);
SELECT phraseto_tsquery('english', :q);

-- 3) インデックス統計
EXPLAIN ANALYZE
SELECT * FROM docs WHERE to_tsvector('english', body) @@ websearch_to_tsquery('english', :q);

インデックスとパフォーマンス(GIN/tsvector)

-- 典型的な GIN インデックス
CREATE INDEX IF NOT EXISTS idx_docs_body_fts
ON docs USING GIN (to_tsvector('simple', body));

-- @@ に合わせる(辞書/構成を揃える)
SELECT * FROM docs
WHERE to_tsvector('simple', body) @@ websearch_to_tsquery('simple', :q)
ORDER BY ts_rank_cd(to_tsvector('simple', body),
                    websearch_to_tsquery('simple', :q)) DESC;

・to_tsvector と tsquery の「構成名(辞書)」は一致させる

・構成が違うとヒットしない/遅い/思わぬ除去が起きる

アプリ言語からの安全実装(例:Node/Python)

// Node (node-postgres)
const q = req.query.q ?? '';
const sql = `
  WITH x AS (SELECT websearch_to_tsquery('simple', $1) AS tsq)
  SELECT id, title
  FROM articles, x
  WHERE x.tsq <> ''::tsquery
    AND to_tsvector('simple', body) @@ x.tsq
  ORDER BY ts_rank_cd(to_tsvector('simple', body), x.tsq) DESC
  LIMIT 20
`;
const rows = await client.query(sql, [q]);

# Python (psycopg)
q = request.args.get("q", "")
sql = """
WITH x AS (SELECT websearch_to_tsquery('simple', %s) AS tsq)
SELECT id, title
FROM articles, x
WHERE x.tsq <> ''::tsquery
  AND to_tsvector('simple', body) @@ x.tsq
ORDER BY ts_rank_cd(to_tsvector('simple', body), x.tsq) DESC
LIMIT 20
"""
cur.execute(sql, (q,))

再発防止チェックリスト

□ ユーザー入力 → websearch_to_tsquery / phraseto_tsquery / plainto_tsquery を使用
□ to_tsquery を使う場合は機械生成のみ(構文保証済み)
□ 空 tsquery('')のときの分岐を実装(検索スキップ/全件抑止)
□ to_tsvector と tsquery の構成名(辞書)を統一
□ 未対応記号を“消す”より、ファンクションに任せる(正規化は最小限)
□ フレーズ検索は phraseto_tsquery か <-> を使用
□ デバッグ時は ts_debug で辞書の動作を観察

通し例:壊れやすい入力を安全に検索(C++ / “foo bar” / -baz)

-- 対象テーブル
CREATE TABLE docs(id serial PRIMARY KEY, body text);
CREATE INDEX IF NOT EXISTS idx_docs_fts
ON docs USING GIN (to_tsvector('simple', body));

-- サンプルデータ
INSERT INTO docs(body) VALUES
 ('C++ で foo bar を処理する'),
 ('C# で foo を試す'),
 ('baz を含む別記事');

-- NG:to_tsquery に直渡し(エラーになり得る)
-- SELECT * FROM docs
-- WHERE to_tsvector('simple', body) @@ to_tsquery('simple', 'C++ "foo bar" -baz');

-- OK:websearch_to_tsquery に任せる
SELECT id, body
FROM docs
WHERE to_tsvector('simple', body) @@ websearch_to_tsquery('simple', 'C++ "foo bar" -baz')
ORDER BY ts_rank_cd(to_tsvector('simple', body),
                    websearch_to_tsquery('simple', 'C++ "foo bar" -baz')) DESC;