コンテンツにスキップ

溢れ分の tree — 設計

Status: 2.6 ラインの設計であり、挙動ではない。本ページは、長いレコードを先頭の数百字ではなく 関連する部分で引用できるようにするための保存層を決めます。最初の用途は 再構成想起の抜粋選択です。 recall がどのレコードをどの順で返すかは変えません。

0. 欠陥と、この最初の段階がそれに対してすること

埋め込みモデルが読めるトークン数には上限があります。その窓を超えた部分は保存され、字面の 検索では引けますが、ベクトルには届きません。そして長いレコードが返されると、読み手が受け取る のは先頭から切ったプレビューです。本番コーパス 1,625 行を bge-m3 の 512 トークン窓で測ると、 窓が閉じる位置はテキストによって 739 文字目から 1,787 文字目まで散らばりました。文字数の 定数でこれを代用することはできません。

埋め込みサーバーは、テキストごとにその位置を報告するようになりました (POST /count_tokens、CEmbedding 0.8.0)。本設計はそれを使って長いレコードをノードに 分けます。ノードはレコードの連続した区間で、それぞれ丸ごと埋め込めるだけの小ささです。

最初の段階は意図して狭くしてあります:

本設計 別途決める
ノードを作り、保存し、埋め込む する
返されたレコードを最も関連するノードで引用する 可能にする (再構成想起が使う)
ノードを検索の候補にし、尾からそのレコードを結果に入れる しない する — §7
保存の長さ上限を撤廃する しない する — §7

ノードは検索の索引に入らないので、recall が返すレコードとその順序は構造上変わりません。 ノードを索引に入れた時の検索側の利得は計測済みで、分割されていないレコードが払う代償も 計測済みです。その取引は、独自の事前登録を持つ別の判断です。

1. スキーマ

テーブルを 1 つ追加します。memories と episodes は何も変わりません。

CREATE TABLE IF NOT EXISTS record_nodes (
    parent_kind     TEXT    NOT NULL,   -- 'mem' | 'ep'
    parent_id       INTEGER NOT NULL,
    node_index      INTEGER NOT NULL,   -- 0, 1, 2, ... テキスト順
    start_char      INTEGER NOT NULL,   -- 親テキスト内の開始オフセット (含む)
    end_char        INTEGER NOT NULL,   -- 終了オフセット (含まない)
    token_count     INTEGER NOT NULL,   -- 埋め込みサーバーが数えた値
    window          INTEGER NOT NULL,   -- 区間を切った時の窓
    embedding       BLOB,
    embedding_model TEXT    NOT NULL DEFAULT '',
    PRIMARY KEY (parent_kind, parent_id, node_index)
);
  • ノードは本文ではなくオフセットを持ちます。 ノードの本文は parent_text[start_char:end_char] です。保存された記憶はコピーも変更もされず、 どのレコードの 2 つ目のコピーも増えません。
  • memories の行ではなく別テーブルにします。 パッケージ内で memories を読むクエリは 80 を超えます (recall、一覧、重複排除、件数、ヘルス、export、削除、分離軸)。ノードを記憶の 行として保存すると、そのすべてに除外条件が要り、1 か所でも漏れればノードが結果や件数に 混ざります。別テーブルなら既存の経路はどれも変わりません。
  • 記憶と episode で 1 つのテーブルにします。 最も長いレコードは episode の要約なので、 episode も最初から対象にします。
  • マイグレーションはテーブルとそのトリガーを追加するだけです。既存のテーブルの作り替えは ありません。

2. レコードの分け方

分割するのは、テキストが窓を超えるレコードだけです。窓に収まるレコードはノードを持たず、 レコードそのものが唯一の区間です。

次の規則を、残りのテキストが無くなるまで適用します:

  1. 残りのテキストで窓が閉じる位置 (window_end_char) を埋め込みサーバーに尋ねる。 残りが窓に収まれば、それが最後のノード。
  2. その位置以前で最後の切れ目で切る。切れ目の種類は次の順に試す: 空行 (段落)、改行 (行・箇条書き項目)、文末、空白。全角の 。 . ! ? は位置を問わず文末とする (日本語・中国語の文章は次の文を直後に続けるため)。半角の . ! ? は後ろが空白かテキスト末尾の時だけ文末とし、小数点やファイル拡張子を文末と読まない。
  3. ノードが window_end_char の半分より短くなる切れ目は採らず、次の種類を試す。早い位置の 段落区切りが数語のノードを作るのを防ぐため。
  4. どの種類にも採れる切れ目が無ければ、window_end_char そのもので切る。
  5. 切った位置の後ろのテキストで続ける。

これで得られる性質:

  • どのノードも窓に収まります。 各切断は、テキスト全体の計数から導くのではなく、実際に 埋め込む区間で測るので、モデルに切り詰められるノードはありません。文字数の定数は関与せず、 モデルや窓が違えばそれ自身の分割になります。
  • 決定論的です。 同じテキスト、トークナイザ、窓からは同じノードができます。
  • 連続で漏れがありません。 ノードは隙間も重なりもなく [0, len(text)) を覆います。 この段階では重なりは使いません。
  • 不明は「収まる」ではありません。 埋め込みサーバーがトークンを報告できない時 (外部 API、 古いサーバー、埋め込み無効) はノードを作らず、レコードは今日と同じく先頭から引用されます。

3. ノードを作るタイミング

ノードの構築は store の中ではなく、既存の記憶タスクキューで行います。16,000 字の レコードは約 14 ノードで、それぞれをインラインで埋め込むと、今日ミリ秒で返る呼び出しに 数秒が加わります。

  • store と archive_episode は今と同じく応答します。レコードが窓を超える時は、応答に nodes: {"status": "queued"} を加えます。窓に収まるレコードでは何も加えません。
  • ノードができるまでは、レコードは先頭から引用されます。作成待ちのノードが読み出しを失敗させたり 待たせたりすることはありません。
  • タスクキューが無効の時は、書き込み時にノードを作りません。ノードの無い長いレコードは ヘルスチェック (check_health の missing_nodes) が報告し、その修復がノードを作ります。 この機能より前に書かれたレコードがノードを得るのも、失敗した構築をやり直すのも、以前の 埋め込みモデルのノードを置き換えるのも同じチェックです。修復はノードを足すだけで レコードを変更しないので、ロックされたレコードも対象にします。1 回の実行で作るのは 最大 50 レコードで、分割と埋め込みは書き込みロックを取る前に済ませます。それを超える 分は実行を重ねるごとに解消されます。

4. ノードを親と一致させ続ける

一貫性は呼び出し箇所ではなくトリガーで保ちます。トリガーは本設計の後に書かれる経路も覆う からです:

  • 記憶または episode を削除すると、そのノードも削除される。
  • 記憶の content または episode の summary を変えると、そのノードは削除され、 レコードは新しいノードのためにキューに入る。

記憶と episode の id は再利用されない (AUTOINCREMENT) ので、ノードが別のレコードを指す ようになることはありません。embedding_model が現在のモデルと異なるノードは欠落として扱い、 レコードの埋め込みと同じく作り直します。ノードは export しません: export が既に運ぶテキスト から導けるので、import が作り直します。ノードは親を通してしか読まれないので、親の分離軸と アクセス制御を継承します。

5. 不変条件

  1. 検索は変わらない。 recall と reconstruct は、ノードの有無にかかわらず同じレコードを 同じ順で返す。テスト: コーパスにノードを作り、ノードの無い同じコーパスと結果 id を比べる。 ノードをベクトル検索に入れる変異は、このテストを赤くしなければならない。
  2. 保存されたレコードは変更されない。 ノードの作成・再作成・削除のいずれでも。
  3. 各ノードの token_count はその window 以下。 ノード自身の区間で数えた値として。
  4. ノードは親を分割する。 node_index 順に並べると、最初は 0 から始まり、各ノードは 前のノードの終わりから始まり、最後は親の長さで終わる。
  5. 分割は決定論的。 与えられたテキスト、トークナイザ、窓に対して。
  6. 古いノードは残らない。 親の削除やテキストの変更の後に。

6. この段階でノードが何のためにあるか

再構成想起はまずレコードを選びます。次に、項目の各 claim について、そのレコードのノードを クエリに対して採点し、レコードの先頭の文字ではなく最も関連するノードを引用します。採点の 規則は再構成想起に属し、ここでは決めません。本設計が保証するのは、長いレコードがすべて、 採点できる有界で埋め込み済みの、指し示せる区間を差し出すことだけです。

同じオフセットは、展開の経路にも小さな単位を与えます: 呼び出し側はレコード全体ではなく ノード 1 つを取り出せます。

7. 別途決めること

  • ノードを検索の候補にすること。 尾と語彙を共有しないクエリで長いレコードを測ると、 ある 1 つの順位付け構成で、ノードを索引に入れると上位 10 件への到達が 24.7 ポイント、 1 位が 10.6 ポイント上がりました。利得の大きさはその構成に依存しました。同時に、分割されて いないレコードは 3.6 ポイント下がりました。長いレコードが索引のより多くを占めたためです。 この損失はすべてのクエリにかかるので、尾を狙うクエリが十分に多い時にだけ索引化は勝ちます。 その割合はまだ測っておらず、親の順位を固定してノードのヒットをその下に足す二段構成も 測っていません。どちらも判断の前に行います。
  • 保存の長さ上限の撤廃。 上限は 16,000 字のままです。これは書き込み経路の変更で、 再構成想起には属しません。撤廃が最も意味を持つのは、ノードを検索の候補にすることと 組み合わせた時です。どの検索も届かない尾は、長くなってもほとんど得をしないからです。

8. この段階の判定

  • 検索が変わらないこと: 不変条件 1、変異の下で。
  • 分割の質: 実コーパスで、文の途中から始まるノードの割合。
  • 報告の正確さ: nodes.status とノードのテーブルが保存されたレコードと一致すること。
  • 書き込みのコスト: ノード構築をキューに回した状態の store のレイテンシ、ノードあたりの キュー処理量。
  • 容量: 分割されたレコードあたりに増えるバイト数。