AIニュース最前線
最新ニュースAI日報Hacker日報週報動画AIツールトレンド企業

AIニュース最前線

世界中のAI最新情報を日本語で毎時更新

最新ニュース日報トレンド企業プレミアムRSS
© 2026 ainew.jp特定商取引法に基づく表記
ニュース一覧元記事を開く
LangChain Blog·2026年6月11日 03:53·約24分で読める

SmithDB における全文検索:オブジェクトストレージ用の逆インデックス設計

#RAG#LLM Observability#Agent Tracing#Object Storage#Inverted Index
TL;DR

LangChain は、LLM コンテキストの増大に伴う巨大なエージェント追跡データを扱うため、オブジェクトストレージ上で動作する独自のインデックス設計を SmithDB に実装したと発表した。

AI深層分析2026年6月11日 11:24
4
重要/ 5段階
深度40%
5
関連度30%
4
実用性20%
4
革新性10%
4

キーポイント

1

エージェント追跡データの特殊性への対応

従来の検索エンジンとは異なり、LangSmith のイベントデータは入力・出力ペイロードが数百メガバイトに達する巨大な JSON で構成されており、メタデータよりもコンテンツ列のサイズが桁違いに大きいという特徴がある。

2

オブジェクトストレージ上のインデックス設計

Lucene や Tantivy などの既存技術に加え、第一原理からアプローチし、膨大なコンテキストウィンドウと長時間実行されるエージェントの文脈を効率的に検索・フィルタリングするための独自の逆インデックスを構築した。

3

400ms の低遅延パフォーマンス

SmithDB は、深くネストされた巨大な JSON ドキュメントがオブジェクトストレージに保存されている状況下でも、フルテキスト検索と JSON フィルタリングの中央値(P50)レイテンシを 400 ミリ秒に抑えることに成功している。

影響分析・編集コメントを表示

影響分析

この技術的アプローチは、生成 AI エージェントが複雑化・大規模化する現代において、膨大な実行ログをリアルタイムに分析・検索する際のボトルネックを解消する重要な一歩です。特にオブジェクトストレージ上に直接インデックスを構築し低遅延を実現した点は、クラウドネイティブな AI 監視システムの標準的なアーキテクチャの進化を示唆しており、開発者や運用チームがエージェントの挙動を深く理解するための基盤技術として大きな影響を与えるでしょう。

編集コメント

LLM のコンテキスト拡大に伴うデータ爆発に対し、既存の検索エンジンの延長線上ではなく、ストレージ特性に合わせたゼロベース設計で解決策を提示した点は非常に示唆に富んでいます。

image
image

概要

SmithDB は、オブジェクトストレージに保存された大規模で深くネストされた JSON ドキュメントからなる基盤データであっても、エージェントのトレースに対して全文検索および JSON フィルタリングをサポートし、中央値 (P50) レイテンシは 400 ミリ秒です。

全文検索はすでに確立された分野です。Lucene は 20 年以上の歴史を持ち、Tantivy や Quickwit は既に検索とインデックス化をオブジェクトストレージ上に実装しています。しかし、SmithDB へのテキスト検索機能を実装するにあたり、エージェントのトレースを検索ワークロード向けにインデックス化する課題が固有のものであることを踏まえ、第一原理からアプローチすることを選択しました。

SmithDB は検索に対して異なるアプローチを必要とする

課題 1: エージェントトレースのユニークなデータ特性

すべての LangSmith イベントは、その総バイト数の圧倒的多数を占めるフィールドとして inputs と outputs をエンコードしています。入出力のペイロードサイズが 1 MB+ となることは珍しくなく、一部では圧縮解除状態で数百メガバイトに達するものもあります。これらのコンテンツカラムは、ID、タイムスタンプ、その他のメタデータカラムを桁違いに上回っています。

また、元の SmithDB ブログ投稿 で言及されている通り、エージェントのトレースに関連するペイロードは時間とともにサイズが増え続けています。これは、LLM のコンテキストウィンドウサイズが大きくなり、エージェントがより長い時間範囲で実行される結果として、LLM がより多くのコンテキストを蓄積するためです。

これらの特性は、検索インデックスの通常の経済性を逆転させます。従来のログエンジンは数十億件の*小規模な*ドキュメントをインデックス化するため、インデックスサイズは各ドキュメントに対して相対的に小さくなります。一方、LangSmith のエージェントトレースでは、数十億件の*巨大な*ドキュメントをインデックス化しており、1 つのドキュメントが多数の小規模なログ行よりも多くのインデックスデータを生成します。一般的に、ログにおけるソースデータとインデックスの比率は約 1:1.25 です。しかし、LangSmith のエージェントトレースでは、平均して約 1:1.9 に近いことが観測されています。これにより、4 つの重要な帰結が生じます:

  • インデックスを持たないコンテンツフィルタは壊滅的に遅くなります。「タイムアウトをツール出力が言及する実行を検索する」というクエリを実行する場合、候補範囲内のすべてのペイロードをスキャンしない限り不可能ですが、そうすれば 3 行の結果を得るために何ギガバイトものデータをスキャンすることになります。
  • 用語の出現頻度は Zipf 分布に従います。自然言語や JSON ペイロードはべき乗則(パワーロー)を示し、「agents」「import」「role」「type」や普遍的なキーなどの限られたトークンはほぼすべての文書に現れる一方、長いテールにある用語は一度または二度しか出現しません。インデックスは、用語頻度の何桁ものオーダーにわたってコンパクトで剪定可能である必要があり、それらはすべて 1 つのファイル内に収められます。
  • 複数のクエリモードが重要です。ユーザーはパス(「この実行に inputs.content.messages が含まれるか?」)、値(「…Alex に言及している場所」)、自由テキスト(「…どこかでレイテンシの回帰を言及しているか」)によって検索を行います。

インデックス付き検索は、コンテンツクエリがペイロード全体のスキャンを実行するのを防ぐものであり、重く偏った半構造化ペイロードを受け入れる必要があります。

課題 2: オブジェクトストレージ

SmithDB はすべての永続データをオブジェクトストレージに保持するため、計算ノードは比較的ステートレスとなり、システムはローカルディスクを管理することなくノードを追加することでスケーリングできます。

クエリのコストは概ね (オブジェクトストレージに対して発行されるリクエスト数) × (1 リクエストあたりの読み取りバイト数) に比例します。オブジェクトストレージにおいて:

  • 各オブジェクトストアのリクエストには、数十ミリ秒から数百ミリ秒のレイテンシが伴います。
  • リクエストごとのスループットは限定的であるため、必要な場合にのみ取得する前に、大規模な投稿リストや位置リストを取得しようとすると、クエリ全体のボトルネックになる可能性があります。

SmithDB の逆インデックスのすべての側面、ストレージレイアウトからクエリ実行に至るまで、これらの制約を念頭に置いて設計されています。

SmithDB 検索クエリの形状

逆インデックスのストレージレイアウトについてさらに深く掘り下げる前に、このインデックスが対応する主なクエリパターンを確認しましょう。SmithDB のクエリ表面は、3 つの述語ファミリーに集約され、それぞれが対象とするマッチング内容と許容されるパターン構文において異なります。

  • 第一はパスの存在(json_key)です。このドキュメントにキー K が含まれているか?例えば json_key(inputs, "author.name") は、どのドキュメントが author.name に言及しているかを問うものです。パスの存在判定では、キーパス自体に対する LIKE 演算もサポートされます:json_key(inputs, "author.%") や json_key(inputs, "%.user_id") は第一級クエリとして扱われます。パターンはパス内の任意の位置(プレフィックス、サフィックス、インフィックス)に配置できます。
  • 第二はキー付き値(json_key_search)です。キー K の値が V に一致するか?json_key_search(inputs, "author.name", "Jane") が標準的な形式です。クエリは単一のトークンでも、複数トークンのフレーズ(json_key_search(inputs, "title", "latency regression")でも構いません。フレーズ版では隣接性が追加され、「latency regression」はこれらの単語が値内で連続して出現するドキュメントのみと一致し、値内のどこかに存在すればよいわけではありません。
  • 第三は全文検索(search)です。インデックスされた値のいずれかが Q に一致するか?search(error, "timeout") はテキスト列を直接検索します;search(inputs, "latency regression") はパスに関係なく、すべての JSON 値を対象に検索を行います。

要約すると:

Shape

What it matches

json_key

key path exists

json_key_search

path + value

search

text column or any JSON value

後続の各セクションはこの表を参照します。「パスのみクエリ」と言う場合は json_key を指し、「キー付き値」は json_key_search を、「全文検索」は search を意味します。

インデックス付き検索の概要

インデックス付き検索(inverted index)は、Lucene から Tantivy に至るまで、あらゆる検索ライブラリを支えるデータ構造です。これは教科書の末尾にある索引のようなもので、ある用語を一度参照するだけで、その用語が言及されているページに直接ジャンプでき、すべてのページを読み込む必要がありません。SmithDB はこの考え方を基盤とし、オブジェクトストレージに保存される大規模なエージェント・トレースペイロード用に、ストレージレイアウトを特化させています。

用語、ポスティング、位置情報

インデックス付き検索の構造は、以下の3つの概念に基づいています:

  • 用語(term)は、索引付けの基本単位です。JSONパス、キー付き値、またはテキストトークンとなります。
  • ポスティング(posting)は、その用語を含む文書IDのソート済みセットです。
  • 位置情報(position)は、文書内で用語が出現する場所であり、これがフレーズ検索を可能にします。

テキストに基づいて索引付けされた5つのトレース例を見てみましょう:

doc 0: "langchain agents emit traces"

doc 1: "langsmith engine runs deep agents"

doc 2: "langchain deep agents workflow"

doc 3: "agents emit deep langsmith traces"

doc 4: "deep langsmith powers the engine"

インデックスは、各用語ごとに1エントリを保持し、その用語に言及している文書への参照を含みます:

term posting list positions

────────── ────────────── ─────────────────────────

agents [0, 1, 2, 3] 0:[1] 1:[4] 2:[2] 3:[0]

deep [1, 2, 3, 4] 1:[3] 2:[1] 3:[2] 4:[0]

emit [0, 3] 0:[2] 3:[1]

engine [1, 4] 1:[1] 4:[4]

langchain [0, 2] 0:[0] 2:[0]

langsmith [1, 3, 4] 1:[0] 3:[3] 4:[1]

powers [4] 4:[2]

runs [1] 1:[2]

the [4] 4:[3]

traces [0, 3] 0:[3] 3:[4]

workflow [2] 2:[3]

各用語は辞書エントリの一つです:値を参照し、その投稿リスト(posting list)を読み取れば、どのドキュメントを取得すべきかが明確になります。"search("deep agents")" のようなクエリでは、deep の投稿リスト [1, 2, 3, 4] と agents の投稿リスト [0, 1, 2, 3] を交差させて [1, 2, 3] を得ますが、これはペイロードのスキャンを必要としません。

positions カラムは、各ドキュメントにおいてその用語が出現するトークンのオフセット(位置)を記録しています。例えば "1:[0]" はドキュメント 1 の位置 0 を意味します。これがフレーズ検索を可能にする理由です:"search("langsmith engine")" というクエリは、langsmith がオフセット 0 にあり engine がオフセット 1 にあるためドキュメント 1 に一致しますが(0 + 1 == 1)、powers と the がその間に挟まっているドキュメント 4 には一致しません(langsmith は位置 1、engine は位置 4)。

なぜ Vortex を採用し Tantivy を選ばなかったのか

Tantivy は優れた検索インデックスライブラリであり、Rust における Lucene スタイルの検索のための明白な参照点です。当初はこれを直接採用できるかどうかを問いましたが、最終的にたどり着いた設計は Tantivy に強く影響を受けています。しかし、いくつかの制約により、そのまま私たちのユースケースに適合させるには不自然なものとなりました:

  • オブジェクトストレージであり、ローカルディスクではありません。Tantivy は mmap を中心に構築されており、すべてのバイトがマイクロ秒単位でアクセス可能であり、ランダム I/O は事実上無料です。一方、オブジェクトストレージでは往復に約 100 ミリ秒を要し、レイアウトと結合(coalescing)がクエリの遅延を決定する要因となり、CPU の性能は主要因ではありません。
  • カラム型エンジンに埋め込まれています。SmithDB のクエリは Apache DataFusion を経由して Vortex 上で実行されます。検索機能も他の述語と同じスキャンパイプラインを通じてプッシュダウンされたいと考えており、独自のセグメントモデルと I/O 仮定を持つ並列クエリスタックとして独立して動作させることは望んでいません。
  • ドキュメント ID は Vortex の行とアライメントされています。Tantivy のライターは挿入順で独自のセグメントローカルなドキュメント ID を割り当て、マージのたびに再番号付けを行います。一方、SmithDB ではインデックスが対応する Vortex データファイル内の行位置を直接指す必要があります(コアイベントデータファイルには Vortex を使用しています)。つまり、ドキュメント ID は行インデックスそのものであり、翻訳テーブルは不要で、クエリ実行時に照合すべき第二の識別子も存在しません。また、データファイルの行順序に従うマージ処理でも再マッピングは不要です。さらに、コンパクションによる行位置の再マッピングも、本ブログ記事の後半で詳述する通り、Tantivy のインデックスマージとは相性が良くありません。

SmithDB 向け逆インデックス開発への道のり

Vortex の簡単な解説

Vortex は、SmithDB がオブジェクトストレージに使用する拡張可能かつ列指向のファイル形式です。Parquet などの固定フォーマットとは異なり、Vortex ではプラグイン可能なエンコーディングとカスタムファイルレイアウトを許可しており、これによりファイルフォーマットのフォーク(分岐)を行うことなく、ワークロードに合わせて圧縮や I/O アクセスパターンを最適化できます。

すべての読み取り操作では、統計情報を用いて行グループ全体を*剪定(prune)*し、残った行をマスクまで*フィルタリング*し、クエリが実際に必要とする列のみを*投影(project)*します。

Vortex ファイルにおける I/O の単位はセグメントです。これは連続した物理的なバイト範囲のことです。オブジェクトストレージでは 1 ラウンドトリップに約 100 ms かかるため、クエリレイテンシを削減するための主要なレバーは、リクエスト数を最小化することにあります。Vortex の I/O スケジューラは、近接するセグメント読み取りを単一のリクエストに統合し、1 MB のギャップ内の読み取りを 1 つにまとめます(最大 16 MB のウィンドウまで)。これにより、インデックスにおけるシーケンシャルアクセスパターンが、オブジェクトストアの GET リクエスト数に非常に少ないものに対応します。

image
image

私たちの(失敗に終わった)最初の試み

最初のバージョンは、教科書的な逆インデックスのほぼ直訳でした。2 つのカラム(パス用の term_key とトークン用の term_value)により、1 つのレイアウトで 3 つのクエリ形状すべてに対応可能となりました:*path-existence* は term_key を読み取り、*keyed search* は両カラムにわたる投稿リストを交差させ、*full-text* は term_value のみで交差させます。投稿リストは List<u32> 形式のセルとして保存され、位置情報は List<List<u32>> 形式でした。

Vortex のデフォルト設定に頼りました:term カラムには FSST エンコーディングを、投稿リストと位置情報には bitpacked エンコーディングを、クエリ時にプルーニング(不要なデータの除外)が可能なゾーン化ストレージレイアウトを採用しました。フレーズ検索に必要な位置情報だけが他のすべてのカラムよりも桁違いに大きかったため、インデックスはコアのランデータとは別のファイルに保持することにしました。これにより、インデックスの構築とマージをコアの書き込みパスから切り離すことができました。Vortex の API は行インデックスとマスクを対象とするため、インデックスフィルタリングを兄弟ファイルに委譲する構成は自然なものでした。

image
image

スケーラビリティの観点では、3 つの問題が顕在化しました:

  • 用語ごとの符号化制御がない。Vortex は列全体に対して符号化を選択しており、用語ごとではないため、単一の共通トークン(agent, langchain)がチャンク内のすべての用語に大きなビット幅を強制し、ビットパッキングの効率が低下する。列の残りの部分はキャッシュ動作の悪化と読み込みサイズの増大という代償を支払わされ、高頻度用語に対してのみより積極的なビットパッキングを適用するための手段が存在しなかった。
  • 固定サイズ行グループは用語の偏りを無視していた。各行グループに一定数の用語をバッチ処理したがため、単一の高頻度用語が一つの行グループを圧縮後 100 MB を超えるように押し上げ、別の行グループは数 MB のまま放置される事態が生じた。クエリ実行時にはこれが巨大なオブジェクトストア GET 操作となり、マージ時には巨大なメモリ内デコード処理となった。
  • マージでは位置情報の再構成が必要だった。2 つのセグメントをマージするには、完全な位置情報 List<List<u32>> をデコードし、内部リストを新しいドキュメント順序に並べ替え、すべての外側オフセットを再計算する必要がある。コンパクション時に CPU 時間と割り当て量が急増した。インデックスのバイトの 70% 以上が位置情報である場合、これが主要なコンパクションコストとなった。
image
image

第 2 回試行:V2 インバートドインデックスのストレージレイアウト

私たちの V2 レイアウトは、「1 ロウグループあたりの N 語」という組織単位をバイト予算制約付きのロウグループに変更し、各列ごとのバイトレイアウトを直接 Vortex のデフォルトに依存するのではなく、自前で管理することで、V1 で生じたすべての 3 つの問題に対処します。このセクションの後半では、新しい組織単位の概要、その内部に含まれる要素、そしてなぜそのバイト予算を獲得できたのかというエンコーディングの選択について順を追って解説していきます。

バイト単位でサイズ指定されたロウグループ

ロウグループはプルーニングと I/O の単位であるため、固定された行数ではなく、独立した固定の*バイト*予算に基づいてロウグループのサイズを決定します。

  • 投稿データ用として 32 MB:クエリがロウグループの投稿データを参照する際の、最悪ケースにおけるオブジェクトストア GET の上限を制限します。
  • 生テキストデータ用として 64 MB:1 ロウグループあたりの生バイト数の上限を設定します。

語数ではなく*バイト*単位でサイズ指定することが、V1 の第 3 の問題を解決する鍵となります。語の偏在(タームスキュー)により、語数は I/O サイズを推定するための良い指標とはなり得ません。V1 のロウグループにおいて、頻度の高い語が 1 つでも含まれるだけで、圧縮後であっても 500 MB を超えてしまう可能性があるからです。一方、バイト予算を設定することで、クエリ実行時にオブジェクトストアから取得するデータ量やメモリーフットプリントに対する、あらゆるロウグループの上限値を明確に保証できます。

用語列に対してゾーンストレージレイアウトを採用し、各ロウグループごとの最小値・最大値・カウント情報を保持させることで、クエリプランナーは FST(Finite State Transducer)にアクセスする前に、完全に不要なロウグループをスキップできるようになります。特定のプレフィックスを対象とするパスクエリにおいては、これが最も大きな削減効果をもたらします。なぜなら、多くのロウグループには述語の範囲内に該当するデータがそもそも含まれていないからです。

image
image

行グループ内の構造

各行グループは 4 つの列を保持しています(用語キーには 3 つの列が含まれ、位置情報はスキップされます)。

  • term — バイナリレイアウトであり、そのバイト列は FST(有限状態トランジューサ)です。これは各用語を順序番号(この行グループ内の行インデックス)にマッピングします。当社の FST の利用方法は Tantivy に着想を得ています。
  • term_info — 用語のメタデータ:文書数およびポストイングと位置情報へのオフセットを含みます。
  • postings — バイナリブロッブです。各用語ごとのリストは、128 ドキュメントブロックに分割されたビットパック化された差分値で構成され、残りの 128 ドキュメント未満の部分は VInt(可変長整数)で末尾に付加されます。
  • positions — バイナリブロッブであり、同じエンコーディングを使用します。これは term_value の場合にのみ存在します。パスの有無はドキュメントレベルでの質問であるため、term_key はこの列を完全にスキップします。

検索処理は、辞書への 1 回の走査、オフセットテーブルの読み取り、および 1 回のバイト範囲フェッチで構成されます。FST が用語を順序番号に解決し、その順序番号が term_info をインデックスして、ポストイングへのオフセット(およびフレーズクエリの 경우)位置情報へのオフセットを取得します。クエリはこれらのバイト範囲を直接読み取ります。ペイロードのスキャンもネストされたリストのデコードも行われません。また、各列が独立したチャンク化レイアウトであるため、フレーズでないクエリでは term と term_info および postings のみをフェッチし、positions 列を開くことはありません。

image
image

エンコーディングの選択

用語辞典には FST を使用します。 279 万回の用語出現を持つ代表的な行グループにおいて、FST を明白な代替案(Vortex のデフォルトである FSST 文字列エンコード、プレフィックス共有 keep_add、および単純な zstd)と比較しました。勝敗の形状は一意性(cardinality)に依存します:

Column | Unique terms | Raw | FSST | zstd | FST

---|---|---|---|---|---

term_key (JSON paths) | 546 | 88.8 MiB | 34.7 MiB | 16.3 KiB | 3.8 KiB

term_value (token values) | 1.41M | 55.1 MiB | 65.7 MiB | 21.7 MiB | 32.7 MiB

term_value:term_key combined | 2.79M | 146.6 MiB | 81.7 MiB | 31.3 MiB | 37.6 MiB

term_key では、数百万行にわたって数百の JSON パスが繰り返されるため、FST は辞典全体を3.8 KiBに圧縮します。これは生バイトの 4 桁小さく、zstd の約 4 分の 1 です。高一意性の term_value カラムでは、FST は zstd よりも約 1.5 倍大きくなりますが、それでも FSST を上回ります。重要な点は、zstd が不透明であることです。すべての参照にはブロックの展開が必要です。一方、FST は*インデックスそのもの*です。完全一致検索、プレフィックスおよび範囲スキャン、そしてオートマトンウォーク(LIKE、ファジー、正規表現)はすべて、ハッシュ化なしで圧縮バイトに対して直接実行され、計算コストは O(|term|) です。

また、キー検索とフルテキストクエリの形状を、1 つの行グループごとに単一の FST に統合します。そのために、term_value エントリを {token}\0{flattened_path} として保存します。キー検索は完全一致による FST ルックアップとなり、フルテキスト検索は token\0 に対するプレフィックススキャンとなり、そのトークンが出現するすべてのパスを走査します。

各用語に対してブロックビットパック化された差分を使用しています。 ポスティングと位置情報の両方で、同じ Tantivy/Lucene スタイルの 2 層エンコーディングを採用しています。このエンコーディングの形状こそが、用語ごとの制御を可能にし、マージを低コストにする理由です。

各用語ごとのリストは、固定された128 要素ブロックと、128 未満の残りの要素からなるテールに分割されます:

image
image

ブロック内では、ID 自体ではなく連続するドキュメント ID 間の差分を保存し、そのブロックの最大差分に収まる最小幅でビットパック化します。密集した規則的な ID の列は、それぞれ数ビットにまで圧縮されます。末尾の不完全なブロック(高頻度用語では定義上稀であり、低頻度用語ではPostingリスト全体がこれに該当)については VInt にフォールバックし、小さな差分に対して約 1 バイトとなり、長いテールでも緩やかに性能を低下させます。

この方式により、v1 の List<u32> エンコーディングにはなかった 2 つの特性が生じます:

  • 用語ごとの符号化であり、列ごとの符号化ではありません。各用語はブロックごとに独自のビット幅を選択します:頻出する用語(例:agent)は文書あたり 3〜4 ビットで圧縮され、稀な用語は VInt の末尾部分から決して離れません。v1 では列全体に一つの幅を強制していたため、頻出用語がすべてのデータのバイト数を膨らませていました。
  • Vortex には非公開です。Vortex は符号化されたバイト列を単一のバイナリ・ブロブとして認識するだけで、読み取りパスで Arrow にデコードすることはありません。これにより、クエリは必要なバイト範囲のみを取得し、必要に応じてブロックをデコードし、スキップリストのルールで除外される部分をスキップしてデコードを飛ばすことが可能になります。

FST 利用における Tantivy との相違点

Tantivy も FST を活用していますが、セグメントごとに 1 つずつの FST を構築し、シャード分割を行います。一方、私たちは行グループごとに 1 つずつの FST を構築します。行グループサイズの FST は十分に小さいため、書き込み側はこれを通じてストリーム処理を行い、メモリ上にセグメント全体の FST を保持する必要がありません。また、クエリ実行時に FST の処理が行われる前に、ゾーンレベルのプルーニングによってほとんどの行グループが除外されます。トレードオフとして、1 回の参照でファイルあたり複数の FST にアクセスする可能性がありますが、プルーニングによりこのコストは実際には稀になります。残存する FST は十分に小さいため、その探索処理は軽量です。

次のステップ

パート 2 では、逆インデックスの構築とマージの実装方法、および読み取りパスでどのようにインデックスを活用しているかについて探ります。

‍

*私たちは、エージェントの観測性に伴うシステム課題を解決するために SmithDB を構築しています。このようなインフラストラクチャ開発に興味がある場合、*採用情報はこちら*です。*

‍

image
image

エージェントが実際に何をしているかを確認する

LangSmith は、当社のエージェントエンジニアリングプラットフォームであり、開発者がすべてのエージェントの意思決定をデバッグし、変更の評価を行い、ワンクリックでデプロイできるように支援します。

原文を表示

Overview

SmithDB supports full-text search and JSON filtering over agent traces with a median (P50) latency of 400 ms, even though the underlying data consists of large, deeply nested JSON documents stored in object storage.

Full-text search is well-trodden ground. Lucene is two decades old; Tantivy and Quickwit have already pushed search and indexing onto object storage. However, when building text search into SmithDB, we decided to approach the problem from first principles because indexing agent traces for search workloads presents a unique challenge.

SmithDB requires a different approach to search

Challenge 1: Unique data characteristics of agent traces

Every LangSmith event encodes the fields inputs and outputs as the overwhelming majority of its total bytes. 1 MB+ payload sizes for inputs and outputs are common, with some of these stretching to hundreds of megabytes uncompressed. These content columns dwarf the identity, timestamp, and other metadata columns by orders of magnitude.

Additionally, as mentioned in the original SmithDB blogpost, the payloads associated with agent traces continue to increase in size over time. This is direct result of LLM context window sizes growing larger and agents running for longer time horizons, causing LLMs to accumulate more context.

These characteristics invert the usual economics of a search index. A traditional log engine indexes billions of *small* documents, so the index is small relative to each document. We index billions of *enormous* documents, where one document can produce more index data than many small log lines. Typically the source:index ratio for logs is about to 1:1.25. However for agent traces in LangSmith, we observed the average to be closer to 1:1.9. Four things follow:

  • A content filter without an index is catastrophically slow. "Find runs whose tool output mentions a timeout" cannot scan every payload in the candidate range otherwise it would scan many gigabytes to return three rows.
  • Term frequencies follow a Zipfian distribution. Natural-language and JSON payloads follow a power law: a handful of tokens ("agents", "import", "role", "type", ubiquitous keys) appear in nearly every document, while the long tail of terms appear once or twice. The index must stay compact and prunable across many orders of magnitude of term frequency, all inside one file.
  • Multiple query modalities matter. Users query by path ("does this run have inputs.content.messages?"), by value ("…where it mentions Alex"), and by free text ("…mentions a latency regression anywhere").

An inverted index is what prevents a content query from performing a full payload scan, and it has to absorb heavy, skewed, semi-structured payloads.

Challenge 2: Object storage

SmithDB keeps all durable data in object storage so compute is relatively stateless and the system scales by adding nodes without having to manage local disks.

The cost of a query is roughly proportional to (requests issued to object storage) × (bytes read per request). On object storage:

  • Each object store request carries tens of milliseconds to hundreds of milliseconds of latency.
  • Per-request throughput is modest, so fetching a large postings list or positions list before you know you need it can dominate the query.

Every aspect of SmithDB’s inverted index, from its storage layout to query execution is designed with these constraints in mind.

SmithDB search query shapes

Before going deeper into the storage layout for our inverted index, let’s go over the main query patterns the index has to answer. SmithDB's query surface boils down to three predicate families, and they differ in what they match against and what pattern syntax they admit.

  • The first is path existence (json_key): does this document contain key K? For example json_key(inputs, "author.name") asks which documents mention author.name. Path existence also supports LIKE on the key path itself: json_key(inputs, "author.%") or json_key(inputs, "%.user_id") is a first-class query. Patterns can land anywhere in the path (prefix, suffix, infix).
  • The second is keyed value (json_key_search): does key K have a value matching V? json_key_search(inputs, "author.name", "Jane") is the canonical form. The query may be a single token or a multi-token phrase (json_key_search(inputs, "title", "latency regression")), and the phrase variant adds adjacency: "latency regression" matches only documents where those words appear consecutively, not anywhere in the value.
  • The third is full-text search (search): does any indexed value match Q? search(error, "timeout") searches a text column directly; search(inputs, "latency regression") searches across every JSON value, regardless of path.

To summarize:

Shape

What it matches

json_key

key path exists

json_key_search

path + value

search

text column or any JSON value

Every later section refers back to this table: when we say "path-only query" we mean json_key, "keyed value" means json_key_search, and "full-text" means search.

An overview of inverted indexes

An inverted index is the data structure powering every search library, from Lucene toTantivy. It is like the index at the back of a textbook: look up a term once and jump straight to the pages that mention it instead of reading every page. SmithDB builds on this idea and specializes the storage layout for the large agent-trace payloads it stores in object storage.

Terms, postings, positions

The inverted index structure rests on three concepts:

  • a term is the unit we index: a JSON path, a keyed value, or a text token
  • a posting is the sorted set of document IDs that contain a term
  • a position is where in a document a term appears, which is what makes phrase searchpossible.

Take five traces indexed on their text:

code

doc 0: "langchain agents emit traces"

doc 1: "langsmith engine runs deep agents"

doc 2: "langchain deep agents workflow"

doc 3: "agents emit deep langsmith traces"

doc 4: "deep langsmith powers the engine"

code

The index keeps one entry per term, pointing at the documents that mention it:

code

term posting list positions

────────── ────────────── ─────────────────────────

agents [0, 1, 2, 3] 0:[1] 1:[4] 2:[2] 3:[0]

deep [1, 2, 3, 4] 1:[3] 2:[1] 3:[2] 4:[0]

emit [0, 3] 0:[2] 3:[1]

engine [1, 4] 1:[1] 4:[4]

langchain [0, 2] 0:[0] 2:[0]

langsmith [1, 3, 4] 1:[0] 3:[3] 4:[1]

powers [4] 4:[2]

runs [1] 1:[2]

the [4] 4:[3]

traces [0, 3] 0:[3] 3:[4]

workflow [2] 2:[3]

code

Each term is one dictionary entry: look up the value, read its posting list, and you know exactly which documents to fetch. A query like search("deep agents") intersects the posting lists for deep ([1, 2, 3, 4]) and agents ([0, 1, 2, 3]) to get [1, 2, 3] with no payload scan.

The positions column records, per document, the token offset(s) where the term appears, e.g. 1:[0] means doc 1, position 0. That is what makes phrase search possible: search("langsmith engine") matches doc 1 because langsmith is at offset 0 and engine at offset 1 (0 + 1 == 1), but not doc 4, where powers and the sit between them (langsmith at 1, engine at 4).

Why we leveraged Vortex and not Tantivy

Tantivy is an excellent search indexing library and the obvious reference point for Lucene-style search in Rust. We started by asking whether we could adopt it directly. The design we ended up with is heavily inspired by Tantivy, but a few constraints made it an awkward fit for our use-case directly:

  • Object storage, not local disk. Tantivy is built around mmap; every byte is microseconds away and random I/O is effectively free. We're on object storage with ~100 ms round trips, where layout and coalescing decide query latency, not CPU.
  • Embedded in a columnar engine. SmithDB queries run through Apache DataFusion over Vortex. We wanted search to push down through the same scan pipeline as every other predicate, not run as a parallel query stack with its own segment model and IO assumptions.
  • Doc IDs aligned with Vortex rows. Tantivy's writer assigns its own segment-local doc IDs in insertion order and renumbers them on every merge. SmithDB needs the index to point directly at row positions in the corresponding Vortex data file (we use Vortex for our core event data files), so a doc ID is a row index — no translation table, no second identity to reconcile at query time, and merges that follow the data file's row ordering need no remap. Additionally, our compaction remaps the row positions which also doesn’t work well with Tantivy’s index merge as we’ll detail in the second part of this blog post.

Our journey to develop SmithDB’s inverted index

Quick primer on Vortex

Vortex is an extensible and columnar file format SmithDB uses for object storage. Unlike fixed formats such as Parquet, Vortex allows pluggable encodings and custom file layouts which lets us tailor compression and I/O access patterns to our workload without forking the file format.

Every read *prunes* entire row groups using statistics, *filters* surviving rows down to a mask, and *projects* only the columns the query actually needs.

The unit of I/O in a Vortex file is a segment: a contiguous physical byte range. On object storage a round-trip costs roughly 100 ms, so the primary lever for query latency is minimizing the number of requests. Vortex's I/O scheduler coalesces nearby segment reads into a single request, merging reads within a 1 MB gap into one, up to a 16 MB window, so sequential access patterns in the index map to very few object store GETs.

Our (unsuccessful) first attempt

The first version was a near-literal translation of the textbook inverted index. Two columns (term_key for paths and term_value for tokens) lets one layout serve all three query shapes: *path-existence* read term_key, *keyed search* intersected postings across both columns, and *full-text* intersected on term_value alone. Postings were stored as List<u32> cells, positions as List<List<u32>>.

We leaned on Vortex's defaults: FSST encoding for the term columns, bitpacked encoding for postings and positions, and a zoned storage layout that allowed for pruning at query time. Positions (required for phrase search) alone were an order of magnitude larger than every other column, so we kept the index in a separate file from the core run data. This let us decouple index construction and merge from the core write path. Vortex's APIs work on row indices and masks, so delegating index filtering to a sibling file composed naturally.

Three problems showed up at scale:

  • No per-term encoding control. Vortex picked the encoding for the whole column, not per term, so a single common token (agent, langchain) forces a larger bit width on every term in the entire chunk, leading to poor bitpacking. The rest of the column paid for it with worse cache behavior and larger reads, and we had no lever to apply more aggressive bitpacking selectively to high-frequency terms.
  • Fixed-size row groups were blind to term skew. We batched a fixed number of terms per row group, which meant a single high-frequency term could push one row group past 100 MB compressed while another sat at a few MB. At query time that turned into one outsized object-store GET; at merge time it turned into outsized in-memory decode.
  • Merge had to reshape positions. Merging two segments meant decoding the full positions List<List<u32>>, reshuffling inner lists into the new document order, and recomputing every outer offset. CPU time and allocations both spiked on compaction. For an index where 70%+ of bytes are positions, this was the dominant compaction cost.

Second attempt: V2 Inverted index storage layout

Our v2 layout addresses all three v1 problems by changing the unit of organization from "N terms per row group" to a byte-budgeted row group, and by owning the byte layout per column instead of directly relying on Vortex defaults. The rest of this section walks through the new unit of organization, what lives inside it, and the encoding choices that earned the byte budget.

Row groups, sized in bytes

Since a row group is the unit of pruning and I/O, we determine the row group sizes with fixed independent *byte* budgets instead of a fixed row count.

  • 32 MB of posting bytes: bounds the worst-case object-store GET when a query reads postings for a row group.
  • 64 MB of raw term-string bytes: caps raw bytes per row group.

Sizing in *bytes*, not term count, is what fixes v1's third problem. Term skew makes term count a poor proxy for IO size, as one high-frequency term in a v1 row group could push it past even 500 MB compressed. The byte budgets give us an upper bound on every row group for the amount of bytes fetched from object store or memory footprint while executing queries.

Per-row-group min/max/count via a zoned storage layout on the term column lets the query planner skip entire row groups before touching the FST. For path queries that target a specific prefix this is the single biggest saving: most row groups simply don't contain anything in the predicate's range.

Inside one row group

Each row group carries four columns (three for term_key, which skips positions):

  • term — a binary layout whose bytes are an FST (finite state transducer) mapping each term to an ordinal (its row index inside this row group). Our usage of FSTs is inspired by Tantivy.
  • term_info — term metadata: doc count plus offsets into postings and positions.
  • postings — binary blob. Per-term lists are split into 128-doc blocks of bitpacked deltas with a VInt tail for the leftover < 128 docs.
  • positions — binary blob, same encoding. Only present on term_value; path existence is a document-level question, so term_key skips this column entirely.

A lookup is one walk through the dictionary, one offset table read, and one byte-range fetch. The FST resolves the term to an ordinal. The ordinal indexes into term_info, which gives an offset into postings and (for phrase queries) an offset into positions. The query reads those byte ranges directly. No payload scan, no nested-list decode, and because each column is its own chunked layout, a non-phrase query fetches just term + term_info + postings and never opens the positions column.

Encoding choices

We use FST for the term dictionary. We compared FST against the obvious alternatives (Vortex's default FSST string encoding, prefix-shared keep_add, and plain zstd) on a representative row group with 2.79M term occurrences. The shape of the win depends on cardinality:

Column

Unique terms

Raw

FSST

zstd

FST

term_key (JSON paths)

546

88.8 MiB

34.7 MiB

16.3 KiB

3.8 KiB

term_value (token values)

1.41M

55.1 MiB

65.7 MiB

21.7 MiB

32.7 MiB

term_value:term_key combined

2.79M

146.6 MiB

81.7 MiB

31.3 MiB

37.6 MiB

On term_key, where a few hundred JSON paths repeat across millions of rows, the FST collapses the entire dictionary to 3.8 KiB: four orders of magnitude smaller than the raw bytes and ~4× smaller than zstd. On the high-cardinality term_value column, FST is ~1.5× larger than zstd but still beats FSST. The crucial point is that zstd is opaque: every lookup requires decompressing the block. The FST is the *index itself* — exact lookup, prefix and range scans, and automaton walks (LIKE, fuzzy, regex) all run directly against the compressed bytes with O(|term|) cost and no hashing.

We also fold the keyed-search and full-text query shapes into a single FST per row group by storing term_value entries as {token}\0{flattened_path}. Keyed search becomes exact FST lookup; full-text search becomes a prefix scan on token\0, walking every path the token appears under.

We use block-bitpacked deltas per term. Postings and positions both use the same Tantivy/Lucene-style two-tier encoding. The shape of the encoding is what makes per-term control possible and what makes merge cheap.

Each per-term list is split into fixed 128-element blocks plus a tail of < 128 leftovers:

Within a block we store deltas between successive doc IDs, not the IDs themselves, and bitpack the block to the minimum width that fits its max delta. A dense, regular run of IDs packs down to just a few bits each. The trailing partial block (by definition rare for high-frequency terms, and the entire posting list for low-frequency ones) falls back to VInt, ~1 byte per small delta, degrading gracefully on the long tail.

Two properties fall out of it that the v1 List<u32> encoding didn't have:

  • Per-term encoding, not per-column. Each term picks its own bit widths block-by-block: a frequent term like agent packs at 3–4 bits per doc, a rare term never leaves its VInt tail. v1 forced one width across the whole column, so frequent terms inflated everyone's bytes.
  • Opaque to Vortex. Vortex sees the encoded bytes as a single binary blob; it never decodes them into Arrow on the read path. That's what lets a query fetch just the byte range it needs, decode blocks on demand, and skip-decode past everything the skip list rules out.

Where we diverge from Tantivy with FST usage

Tantivy also leverages FSTs, but builds one FST per segment with sharded partitioning. We build one FST per row group. A row-group-sized FST is small enough that the writer streams through it without ever holding a segment-wide FST in memory, and zone-level pruning skips most row groups before any FST work happens at query time. The trade-off is that a single lookup may touch multiple FSTs per file, but pruning makes that cost rare in practice; the surviving FSTs are small enough that the walks are cheap.

What’s next

In part 2, we’ll explore how we implemented inverted index construction and merging, as well as how we leverage the index in our read path.

‍

We’re building SmithDB to solve the systems problems that come with agent observability. If that kind of infrastructure work sounds interesting, we’re hiring.

‍

See what your agent is really doing

LangSmith, our agent engineering platform, helps developers debug every agent decision, eval changes, and deploy in one click.

この記事をシェア

関連記事

AI News★42026年6月11日 19:42

Xebia:適切なデータ基盤なしでは AI エージェントは失敗する理由

Xebia のグローバル CTO、ニールス・ゼイルメーカー氏は、組織がプロセス加速のために AI エージェントを導入する場合、AI が利用可能な形でデータを整備することから始める必要があると指摘している。

TechCrunch AI★42026年6月11日 01:11

メモリツールが AI モデルの性能を低下させる理由

TechCrunch AI は、AI モデルに実装されたメモリツールの使用が、かえってモデルの精度や信頼性を低下させる可能性について分析している。

AWS Machine Learning Blog★42026年6月11日 00:21

Amazon Bedrock AgentCore を活用した AI 搭載機器修理アシスタントの構築方法

AWS は、Amazon Bedrock AgentCore を使用して、農機具の故障診断を支援する AI アシスタントを構築する方法を紹介している。これにより、部品不足による再訪問や稼働停止時間の削減が期待される。

今日のまとめ

AI日報で今日の重要ニュースをまとめ読み

ニュース一覧に戻る元記事を読む