溢れ分の 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. レコードの分け方¶
分割するのは、テキストが窓を超えるレコードだけです。窓に収まるレコードはノードを持たず、 レコードそのものが唯一の区間です。
次の規則を、残りのテキストが無くなるまで適用します:
- 残りのテキストで窓が閉じる位置 (
window_end_char) を埋め込みサーバーに尋ねる。 残りが窓に収まれば、それが最後のノード。 - その位置以前で最後の切れ目で切る。切れ目の種類は次の順に試す: 空行 (段落)、改行
(行・箇条書き項目)、文末、空白。全角の
。.!?は位置を問わず文末とする (日本語・中国語の文章は次の文を直後に続けるため)。半角の.!?は後ろが空白かテキスト末尾の時だけ文末とし、小数点やファイル拡張子を文末と読まない。 - ノードが
window_end_charの半分より短くなる切れ目は採らず、次の種類を試す。早い位置の 段落区切りが数語のノードを作るのを防ぐため。 - どの種類にも採れる切れ目が無ければ、
window_end_charそのもので切る。 - 切った位置の後ろのテキストで続ける。
これで得られる性質:
- どのノードも窓に収まります。 各切断は、テキスト全体の計数から導くのではなく、実際に 埋め込む区間で測るので、モデルに切り詰められるノードはありません。文字数の定数は関与せず、 モデルや窓が違えばそれ自身の分割になります。
- 決定論的です。 同じテキスト、トークナイザ、窓からは同じノードができます。
- 連続で漏れがありません。 ノードは隙間も重なりもなく
[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. 不変条件¶
- 検索は変わらない。 recall と reconstruct は、ノードの有無にかかわらず同じレコードを 同じ順で返す。テスト: コーパスにノードを作り、ノードの無い同じコーパスと結果 id を比べる。 ノードをベクトル検索に入れる変異は、このテストを赤くしなければならない。
- 保存されたレコードは変更されない。 ノードの作成・再作成・削除のいずれでも。
- 各ノードの
token_countはそのwindow以下。 ノード自身の区間で数えた値として。 - ノードは親を分割する。
node_index順に並べると、最初は 0 から始まり、各ノードは 前のノードの終わりから始まり、最後は親の長さで終わる。 - 分割は決定論的。 与えられたテキスト、トークナイザ、窓に対して。
- 古いノードは残らない。 親の削除やテキストの変更の後に。
6. この段階でノードが何のためにあるか¶
再構成想起はまずレコードを選びます。次に、項目の各 claim について、そのレコードのノードを クエリに対して採点し、レコードの先頭の文字ではなく最も関連するノードを引用します。採点の 規則は再構成想起に属し、ここでは決めません。本設計が保証するのは、長いレコードがすべて、 採点できる有界で埋め込み済みの、指し示せる区間を差し出すことだけです。
同じオフセットは、展開の経路にも小さな単位を与えます: 呼び出し側はレコード全体ではなく ノード 1 つを取り出せます。
7. 別途決めること¶
- ノードを検索の候補にすること。 尾と語彙を共有しないクエリで長いレコードを測ると、 ある 1 つの順位付け構成で、ノードを索引に入れると上位 10 件への到達が 24.7 ポイント、 1 位が 10.6 ポイント上がりました。利得の大きさはその構成に依存しました。同時に、分割されて いないレコードは 3.6 ポイント下がりました。長いレコードが索引のより多くを占めたためです。 この損失はすべてのクエリにかかるので、尾を狙うクエリが十分に多い時にだけ索引化は勝ちます。 その割合はまだ測っておらず、親の順位を固定してノードのヒットをその下に足す二段構成も 測っていません。どちらも判断の前に行います。
- 保存の長さ上限の撤廃。 上限は 16,000 字のままです。これは書き込み経路の変更で、 再構成想起には属しません。撤廃が最も意味を持つのは、ノードを検索の候補にすることと 組み合わせた時です。どの検索も届かない尾は、長くなってもほとんど得をしないからです。
8. この段階の判定¶
- 検索が変わらないこと: 不変条件 1、変異の下で。
- 分割の質: 実コーパスで、文の途中から始まるノードの割合。
- 報告の正確さ:
nodes.statusとノードのテーブルが保存されたレコードと一致すること。 - 書き込みのコスト: ノード構築をキューに回した状態の
storeのレイテンシ、ノードあたりの キュー処理量。 - 容量: 分割されたレコードあたりに増えるバイト数。