Valkey で実現するエージェントの記憶
クエリの細部に見えるフィルターが、Valkey のベクトル検索方法を変えることがあります。気づく頃には、重要な設計判断をすでに終えているかもしれません。

エージェントの記憶は、単純なベクトル検索の問題に聞こえます。タスクを埋め込みにし、近い記憶を探し、モデルに返す。それで終わりです。
残念ながら、それほど簡単ではありません。記憶は似ているだけでなく、新しくもなければなりません。1年前の記憶にエージェントを左右されたくはないでしょう。結果も重要です。一方の方法が成功し、もう一方が失敗したなら、成功した方を行動の指針にしたいはずです。
エージェントの推論ループ内に Valkey Search を組み込む小さなデモを作る中で、この問題に出会いました。エージェントが終えたタスクをすべて記憶として書き込みます。タスクの文章、有効だった方法、成否、実行時刻、タスクのベクトルを保存します。次に似たタスクが来たときは、最初から見つけ直す代わりに、成功した方法を思い出します。
最初の FT.SEARCH が予想どおり動かなかったため、valkey-search の扱い方を実地で学ぶことになりました。
FT.SEARCH idx:memory "(@outcome:{success} @created_at:[1782900000 +inf])=>[KNN 3 @vector $vec]" PARAMS 2 vec <query-vector> DIALECT 2
=> の左側は、埋め込みと一緒に索引付けした2つのフィールドに対する、タグと数値範囲の通常のブールフィルターです。右側がベクトル検索です。
私は、近いベクトルを見つけてから、タグと時刻のフィルターを適用すると思っていました。しかし、よい意味で違いました。フィルター条件は、実際にはクエリプランナーへの入力です。一致する記憶の数に応じて、Valkey はベクトルインデックスの検索アルゴリズムを変えます。つまりフィルターを追加すると、検索の実行方法そのものが変わります。そのため最初の記憶スキーマを考え直す必要がありました。検索ポリシーは、インデックス設計上の判断でもあったのです。
もはや後処理フィルターではない
Pinecone、Qdrant、Weaviate、Milvus を使っていれば、なじみのある話でしょう。いずれもクエリ時に、フィルターを適用してグラフをたどるか、グラフをやめて一致する部分集合を総当たりするかを決めます。Pinecone は single-stage filtering、Qdrant は query planning、Weaviate は flat search cutoff と呼びます。Milvus には特別な呼び名はなく、そのように動きます。valkey-search も同じ考えで、FT.SEARCH のベクトル経路には後処理のフィルタリングを実装していません。
一方、pgvector では、インデックススキャンの後にフィルターします。初期値の hnsw.ef_search は40なので、候補の約10%を残すフィルターなら、結果は4件だけになるかもしれません。0.8.0で反復インデックススキャンが追加され、多少改善されましたが、初期設定では無効です。
valkey-search はこれをクエリプランナーと呼びます。フィルターに一致するキー数を見積もり、2つの検索アルゴリズムから選びます。
推定数がインデックスに比べて小さければ、事前フィルターを使います。条件を満たすキー集合をたどり、距離を直接計算し、上位 k 件のヒープを保持します。HNSW グラフはたどりません。つまり、小さな集合に対する総当たりスキャンです。
推定数が大きければ、インラインフィルターを使います。述語を isIdAllowed ファンクターとして hnswlib に渡し、最下層を探索する間に評価します。一致しないノードも訪問して展開しますが、結果集合には加えません。
切り替えのしきい値は0.001です。推定一致キー数が、インデックス内のベクトル数の0.1%以下なら総当たりになり、それ以外はインラインフィルターになります。
参考までに、Milvus のしきい値は約7%なので、valkey-search は約70倍厳しい設定です。想像以上にインライン経路が選ばれます。
フィルターの負担をなくす
数百件の記憶を持つデモのインデックスでは、しきい値は1キーを大きく下回ります。そのため1件でも一致すれば、すべてインライン経路になります。この規模では、どちらでも違いに気づきません。
同じスキーマで、本番に1,000,000件の記憶があったらどうなるでしょうか。しきい値は1,000キーです。プランナーはフィルターの選択性を見ます。@outcome:{success} が70%に一致し、@created_at の期間も広ければ、明らかにインライン経路です。その判断は正しいものです。一方、記憶400件の単一テナントに範囲を絞ると、しきい値を下回り、厳密なスキャンになります。距離計算400回は軽いため、完全で高速な検索を得られます。
もう少し難しい場合を考えます。大きなインデックスの1%に一致するフィルターは、しきい値の10倍なのでインライン経路です。HNSW は、残せる候補1件ごとに多くの候補をたどり、想定外のレイテンシが加わります。しかも調整では変えられません。search.prefiltering-threshold-ratio は search.debug-mode が有効でないと変更できず、公開の設定一覧にもありません。
実際にどちらの経路を使っているかは確認できます。valkey-search は search_prefiltering_requests_count と search_inline_filtering_requests_count の両方を数えています。モジュールのフィールドなので、単なる INFO ではなく INFO SEARCH が必要です。同じ検索を100回実行し、どちらが増えるか確認できます。
したがって最善の調整対象は、インデックス自体です。フィルターが名前空間のように働くようにします。インデックス名にハッシュタグを入れ、キーのプレフィックスにも同じタグを付ければ、各テナントの検索は1シャード上の小さなインデックスへ向かい、フィルターの影響はほぼなくなります。Valkey は双方向にこれを強制します。タグ付きのインデックス名では全プレフィックスに同じタグが必要で、タグなしの名前ではどのプレフィックスにもタグを含められません。ただし、ここには FT.ALTER がないため、後で考えを変えても簡単には戻せない点に注意してください。
忘れるコストは高い
HNSW インデックスからベクトルを削除すると、markDelete を呼んで終わります。ノードはグラフから消えず、引き続き訪問され、他の検索の経路にもなります。削除済みの確認に失敗するだけです。search.hnsw-allow-replace-deleted を使えば次の挿入でスロットを再利用できますが、初期値は false で、本番向けというより非本番用のフラグです。本番では、インデックスを削除するまで領域が取り残されます。
幸い、更新は違います。索引付けされたベクトルの変更は、既存ラベルのままその場で更新されます。ノードは同じスロットを保持し、リンクだけが付け直されます。ベクトル以外のハッシュフィールドを書いてもグラフには影響せず、同一バイト列でベクトルを書き直した場合は、その前に処理が終了します。
つまり更新は安く、削除は高くつきます。DEL、追い出し、期限切れも含みます。
これは少し心配です。期限切れこそ、記憶の新しさを保つポリシーだからです。valkey-search は generic、expired、evicted のキースペース通知を購読し、TTL 付きの記憶はキーがなくなるとベクトルインデックスからも確かに消えます。望ましい動作ですが、それがグラフノードを取り残す原因にもなります。
そのため、記憶のキーの付け方を変える必要があります。実行ごとに新しいキーを作るのは安全で合理的な初期設定に思え、私のデモも完了タスクごとに memory:<id> を使っています。しかし、新鮮さを保つ TTL を付けると、失効するキーのたびにノードが残ります。大規模では高価な初期設定です。代わりに、タスクのフィンガープリントから安定したキーを作り、その種類のタスクを学ぶたびに同じ場所で更新します。これなら、試行ごとではなくタスクごとに1ノードで済みます。
新しさを制御する方法は2つあります。@created_at の範囲は、そのクエリで何を信頼するかを決め、何も削除しません。TTL は、どこまで保存費用を払うかを決めます。まず範囲を調整し、TTL はより長い周期の補助手段にするべきです。
データを持つ前に決める
このデモには2度驚かされました。クエリの細部として書いたフィルターが実行アルゴリズムを決め、記憶を新しく保つために追加しかけた TTL が、インデックスの領域を取り残すということです。
少なくとも、すべて調べられます。プランナーのコードは短い関数で、読めば理解できます。フィルターは hnswlib 由来です。削除ノードの動作もソースのコメントにあり、カウンターは INFO SEARCH で見られます。私の説明をそのまま信じなくても、確認できます。
読んでも避けられないのは、判断が必要になる時期です。インデックスの範囲と記憶のキーは、検証に使える記憶が1件もない初日に決めます。開発専用フラグを除けば、削除で取り残された領域を回収するには再構築しかありません。間違えれば、索引を作り直すことになります。
出発点が必要なら、デモを GitHub で公開しています。docker compose up -d で検索モジュール付き Valkey と、自分の記憶を書き込むエージェントを起動できます。いくつかタスクを実行し、INFO SEARCH を見て、クエリが実際にどの経路を使うか確認してください。
開発を楽しんでください。