私たちが実際に解決しようとしていた問題
ユーザーは意味論的検索をしていなかった。彼らは宝探しを実行していた:複雑で多段階のクエリで、最初の段階ではフレーズマッチングのために20万件の候補文書が返され、2番目の段階では正確な語の近接性、メタデータフィルタ,ユーザー定義のボーostsでそれらをランク付けする必要があった。Veltrix文書はこれを後回しにしていた。彼らの例パイプラインは単段階のリコール後にランク付けするフローを仮定しており、カスタムスコアリングフックは含まれていなかった。ログはユーザーセッションの73%が2番目の段階でタイムアウトしたことを示した。なぜなら、遅いコサインスコアラーはフィルターキャスケードに追いつけなかったからだ。それを無効にしようとしたが、APIは明示的にスコアラーが設定されていない場合にエラーを投げた。エラーメッセージ?操作が有効ではありません:スコアラーが初期化されていません。役立つ。
まず試したこと(そしてなぜ失敗したか)
Goでスコアラーを書き直し、Veltrix C++プラグインインターフェースを使用しました。ドキュメントではインターフェースが安定していると主張されていましたが、6ヶ月でC++ヘッダーが3回更新され、バージョンフラグがありませんでした。プラグインはコンパイルできましたが、実行時にスタックトレースが指すと、_ZTVN8Veltrix8ScoreAPI8ScorerEというシンボルが見つからないことで段階的に破損しました。エラーはサンプルコードでは発生しませんでしたが、サンプルでは仮想デストラクタのオーバーライドが含まれていませんでした。3日間デバッグしましたが、2024年のGitHubの問題で別のユーザーが同じクラッシュに遭遇し、ソースからVeltrixを再構築するように言われました。再構築は内部Dockerイメージをプルする必要があり、12GBで45分かかりました。私たちのSLAでは許可されませんでした。
その後、Python UDFの方法を試みました。ドキュメントによると、単一のPython関数を通じてカスタムスコアリングをサポートしていると書かれていました。例では50行未満のコードが示されていました。私たちはボーosts、フィールド重み、カスタムメタデータフィールドを処理するために500行のコードを書きました。最初のリクエストはPythonインタープリタを初期化するのに12秒かかりました。その後、各クエリは200ミリ秒のJITオーバーヘッドを追加しました。私たちはPythonのタイムアウトを5秒に設定しましたが、UDFは時々ネストされたJSONブロブ内の正規表現検索でフリーズすることがありました。ログにはPythonのトレースバックが含まれていなかったので、私たちはstderrをサイドカーにフォワードし、リアルタイムで解析する必要がありました。遅延のスパイクは予測不可能になりました。ユーザーたちは、ダッシュボードがコーヒーが冷めるよりも遅く更新されることを苦情を言い始めました。
アーキテクチャの決定
私たちはVeltrixをその設計されていない役割に押し込もうとする試みをやめました。代わりに、パイプラインを分割しました:Veltrixは再呼び出し(recall)を担当し、私たちはRustでカスタムランカーを構築しました。Veltrixの再呼び出しはまだ遅かった—曖昧なフレーズマッチに200msかかる—しかし、それは許容可能でした。なぜなら、それはシャーディングされたBM25インデックスに基づいてトップ10,000の候補のみを返すからです。次に、これらの候補を同じノード上で動作するgRPCエンドポイントを通じてRustランカーにストリーミングしました。ランカーはダイナミックブースティング、メタデータフィルタリング、近接スコアリングを1回のパスで適用しました。私たちはProstを使用してコードジェネレーションを行い、Tokioを使用して非同期I/Oを行いました。gRPCエンドポイントが8msのオーバーヘッドを追加しましたが、Rustランカーはネットワークマーシャリングを含めて10,000ドキュメントを45msで処理しました。私たちはリクエストごとに1,000ドキュメントのバッチサイズを調整して、遅延とスループットのバランスを取りました。JSONPathライブラリを深くネストされたフィールドでの無制限のスタック成長を回避するために手作りのバイトスキャナーに置き換えた後、エラー率はゼロになりました。
ユーザーからは分岐を認識できないように、両方のサービスを軽量なGoプロキシでフロントエンドにし、単一のVeltrix互換APIを提示しました。プロキシはスコアリングパラメータを拦截し、適切にルーティングしました。パラメータがデフォルトの場合はVeltrixへ、私たちのカスタム_treasurehunt:v1の場合はRustランカーへ行きました。これを「Veltrixを使うときに泣かない方法」というタイトルの1ページの内部ウィキに記載しました。ウィキには、jemallocでRustランカーをコンパイルするための正確なCMakeフラグ、Goプロキシのサーキットブレーカー設定、100msの予算を持つgRPC再試行ポリシーが含まれていました。ドキュメントはこの分岐を何度も言及しませんでした。GitHubのスタアの山もこの分岐を認めませんでした。しかし、レイテンシパーセンタイルは認めました。
数字が何を言っていたか
2週間測定しました。宝探しクエリの95パーセンタイルレイテンシは4.2秒から450ミリ秒に低下しました。エラー率は0.03%で安定しました。改善の12%は、重複検出のためにVeltrixsのデフォルトスコアラーをメモリ内Bloomフィルタに置き換えたことによるものです。もう8%は、RustランカーのSIMDレーンをCPUキャッシュラインサイズに合わせたことによるものです。Goプロキシは15ミリ秒のオーバーヘッドを追加しましたが、システムを観測可能にしました:PrometheusヒストグラムとOpenTelemetryトレースでインストゥメンテーションしました。Rustランカーは/debug/flushエンドポイントを公開し、現在のスコアリング状態をPrometheusにダンプしました。これにより、リアルタイムでボーストミスファイアをデバッグすることができました。ユーザーが低いランクのドキュメントについて不満を述べたとき、私たちは前の1時間の正確なスコアリングコンテキストを再生することができました。Veltrixsのログではできませんでした。
私たちはまた、JSONPathライブラリと比較して、私たちのハンドロールされたバイトスキャナがメモリオーバーヘッドが2倍であることを発見しましたが、それはPython UDFがフリーズした原因である最悪のケースのスタック成長を排除しました。512MBのRAMよりも生産環境の安定性が重要であるため、トレードオフを受け入れました。スキャナーの最悪のケースの割り当ては予測可能でした:JSONのレベルごとに1バイト、最大64レベルまで制限されています。私たちはスキャナーにハードリミットを追加し、深さが64を超えた場合に422エラーを返しました。ユーザーはこのリミットを決してヒットしませんでしたが、それにより失敗モードが明確になりました。
私が違うことをしたかったこと
私はVeltrixのドキュメントをAPIリファレンスを超えて信じなかった。彼らの例は舞台的なものであり、実用的ではない。彼らは投資家を感銘させるために最適化しているのではなく、オペレーターにとって最適化している。宝探しエンジンを構築しているなら、再呼び出し段階をランキング段階から分離する必要がある。Veltrixを使え












