メインコンテンツへスキップ

【続報】Langfuse検索問題のその後について

著者
橘 健介

TL;DR
#

  • 後編で指摘した NFKD 正規化の不整合(カウンターとハイライトの食い違い)は、Langfuse 本体の PR #12961 / #13038(v3.166.0)で解消されました。
  • 上流 @codemirror/search に出した PR #19 自体はマージされず、取り下げになりました。
  • ただしその後、@codemirror/search 6.7.0 で、PR #19 とは異なるアプローチ(マッチに precise フラグを付与し、置換側が安全に弾けるようにする方式)によって SearchCursor の正規化境界の問題に対処されています。
  • これは PR #19 がそのまま採られたわけでも、Langfuse v3.166.0 の修正そのものでもありません。Langfuse は v3.166.0 時点で @codemirror/search^6.6.0 と宣言しており、本記事執筆時点の main では ^6.7.0 に引き上げられています。

はじめに
#

以前、Langfuse v3.158.0 で追加されたメッセージウィンドウ内検索機能(PR #12578)について、2回に分けて記事を書きました。

後編の末尾で、「気になる不整合についてはコミュニティに feedback しておこうと思う、反映されたらまた記事として触れるかもしれない」と書いていました。今回はその続報です。

具体的には、後編で指摘した不整合のうちの一つが、Langfuse 本体および @codemirror/search の上流で対処されたので、その動きをまとめておきます。

後編で指摘した2つの不整合のおさらい
#

後編では、Chat モードの検索について、ソースコードを読んで以下の2つの挙動を観察しました。

  1. NFKD 正規化の不整合: controller.tsbuildMatches()toLocaleLowerCase() + indexOf() でマッチングを行うため NFKD 正規化が効きません。一方、各エディタの highlightSelectionMatches(青色ハイライト)は SearchCursor 経由で NFKD が効きます。結果として、全角の Langfuse で検索したとき、カウンターには 1 / 1(自分自身しかヒットしない)と表示されるのに、エディタ内では半角の Langfuse が青色ハイライトされる、という食い違いが起きます。
  2. case-sensitivity の不整合: highlightSelectionMatchesSearchCursor は NFKD のみが適用され、toLowerCase は適用されません。そのため、Langfuse を検索したとき、Langfuse には青色ハイライトが付きますが、langfuseLANGFUSE には付きません。

Langfuse 本体に入った修正
#

後編公開後、Langfuse 本体には上記 (1) に対応する2つの PR がマージされました。

PR #12961: 独自 StateField ベースのハイライトシステム
#

langfuse/langfuse#12961 (by @bezbac、v3.166.0 で初出)では、CodeMirrorEditor.tsx に独自の StateField ベースのハイライトシステムが構築されました。

これまでは検索ハイライトを @codemirror/searchsearchHighlighter に頼っていましたが、Chat モードではそもそも検索パネルを開かないため searchHighlighter が機能せず、代わりに highlightSelectionMatches がハイライト描画を担っていました。これが、カウンター(controller 側)とハイライト(CodeMirror 側)でロジックが分かれてしまう構造的な原因でした。

PR #12961 の方針は、controller 側で計算したマッチ結果を setSearchHighlightMarks エフェクトとして各エディタに配信し、独自の StateFieldDecoration.mark を生成する、というものです。カウンターとハイライトを単一のロジックから派生させることで、両者の不整合を構造的に発生させないアーキテクチャに移行しています。

PR #13038: buildMatches を SearchQuery.getCursor() に委譲
#

langfuse/langfuse#13038 (by @bezbac、v3.166.0 で初出)では、controller.tsbuildMatches() がリファクタリングされ、toLocaleLowerCase() + indexOf() の代わりに @codemirror/searchSearchQuery.getCursor() を呼ぶようになりました。

SearchQuery.getCursor() は内部的に SearchCursor を生成し、caseSensitive: false の場合は x => x.normalize("NFKD").toLowerCase() を正規化関数として使います。つまり、controller 側のカウンター計算にも NFKD 正規化が効くようになり、LangfuseLangfuse の相互ヒットが成立します。

PR にはテストも追加されており、(U+3322)→ センチ のような互換文字分解を含むケースも検証されています。

この2つの PR の組み合わせにより、後編で指摘した不整合 (1) は解消されたといえます。

@codemirror/search 上流へのアプローチ: PR #19(取り下げ)
#

ここで興味深いのは、Langfuse チームが PR #13038 を実装する過程で、SearchQuery.getCursor() 自体に NFKD 互換文字に関するエッジケースの問題を発見し、上流の @codemirror/search リポジトリに修正 PR を出している点です。

PR は CodeMirror メンテナの Marijn Haverbeke 氏が運営する Forgejo インスタンス上にあり、code.haverbeke.berlin/codemirror/search/pulls/19 で参照できます(GitHub ではない点に注意)。

PR が対象にしようとした問題はこういう構造になっています。SearchCursor は基底正規化関数 x => x.normalize("NFKD") を使いますが、たとえば を NFKD 展開すると センチ の3文字に分解されます。検索クエリが だった場合、展開後の最初の1文字とマッチするはずですが、従来の SearchCursor は展開された文字列の境界判定が甘く、こうした「展開途中で完了するマッチ」を取りこぼすケースがありました。

PR #19 で提案されたのは、概ね以下のような変更です。

  • pending キューを導入し、展開途中で見つかった候補マッチを保留する
  • endsAtNormalizationBoundary() メソッドで、マッチが正規化境界(サロゲートペアや結合文字を切断しない位置)で終わっているかを判定する
  • ループ後、pending キューから順次返す

ただし、この PR はレビュー過程で置換(replace)時の挙動変更について指摘を受けた結果、最終的に取り下げられましたSearchCursor は検索だけでなく置換にも使われるため、検索側で意図したマッチ範囲の拡張が、置換側では予期しない挙動を生む可能性が指摘されたかたちです。 具体的には「合字『㌢』を含むテキストがあった際に、文字列『ン』を一括置換しようとした場合に『㌢』の文字が消えてしまう」ということが論点として挙がり、置換のケースにおける挙動も合わせて考えた結果、前述のPRマージは見送られたようです。

ただし、PR #19 がマージされなかったことと、上流がこの問題を放置したことはイコールではありません。その後 @codemirror/search 6.7.0(2026-04-21)で、Marijn Haverbeke 氏が PR #19 とは異なるアプローチで SearchCursor の正規化境界の扱いを改めています。6.7.0 の CHANGELOG と型定義から読み取れる変更は、概ね次の2点です。

  • 正規化で複数文字に展開される文字の途中で始まる/終わるマッチを、SearchCursor取りこぼさず返すようになった(従来は無視されていた、センチ に対する のようなマッチが返ってくる)
  • 各マッチに precise フィールドが追加され、マッチがそうした展開文字の内側で始まる/終わる場合は precise: false になる。これは fromto の範囲が「実際のマッチに属さない部分」を含むことを示すフラグである

PR #19 が取り下げられた直接の理由は置換時の安全性(合字を含むテキストでの一括置換で合字が壊れる問題)でした。6.7.0 の方式は「マッチ自体は拾うが、precise フラグで区別し、置換側は precise: false を弾けるようにする」もので、まさにこの懸念に応える設計になっています。当初こちらが PR #19 で提案した「マッチを強制的に拾う」方向性とは別解ですが、結果として同じエッジケースが、より置換に安全な形で上流に取り込まれたかたちです。

注意したいのは、これはあくまで上流ライブラリ側の対応であって、Langfuse 上の挙動が自動的に変わることを意味しない点です。Langfuse は v3.166.0 時点で @codemirror/search^6.6.0 と宣言しており(precise を備える 6.7.0 を前提にしていない)、本記事執筆時点の main では ^6.7.0 に引き上げられています。さらに、precise を実際に参照してアプリ側の挙動を変えるかどうかは Langfuse の利用コード次第であり、本記事ではそこまでは追っていません。

後編で指摘したもう一つの問題(case-sensitivity)について
#

なお、後編で指摘した不整合のうち (2) の case-sensitivity については、上記の PR #12961 / #13038、上流 PR #19、および @codemirror/search 6.7.0 のいずれも直接の対象としていません。

ただし PR #12961 は、highlightSelectionMatches への依存自体を捨てて独自のハイライトシステムに移行するアプローチを取っているため、「highlightSelectionMatchesSearchCursor が大文字小文字を区別する」という前提自体が、Chat モードに関しては適用されなくなる方向に進んでいます。

おわりに
#

連載前編・後編で扱ったメッセージウィンドウ内検索機能について、その後の動きを追ってみました。後編公開時点で指摘していた NFKD 正規化の不整合は、Langfuse 本体側の PR #12961 / #13038(v3.166.0)で速やかに修正されました。 そして上流 @codemirror/search 側でも、提案した PR #19 自体はマージされなかったものの、その後 6.7.0 で別アプローチ(precise フラグ)により SearchCursor の正規化境界の扱いが改められました。当初こちらが指摘・提案した不整合が、最終的には上流メンテナの手で、こちらの想定とは異なる「より置換に安全な」形で解消されたかたちです。直接マージには至らなくても、フィードバックや問題提起が上流の改善につながったという意味では、ひとつの貢献の形だったのかなと思います。 残る論点は、この上流の修正を Langfuse 側が利用コードでどう取り込むか、という点に移ります。precise を参照しない限り、合字などの NFKD 展開文字に対する部分一致がアプリ上でどう見えるかは引き続きケースによります。もっとも、日本語の文章で合字を適切に使いつつ部分一致でもヒットさせたい、というケースはかなり限定的なので、実用上の影響はほぼ無視できる範囲でしょう。

本件に限らず、今後も何か気づいたことがあれば共有していきたいと思います。