Gitリポジトリを検索インデックスやナレッジベースへ取り込む際は、同じ入力を繰り返し処理しても結果を壊さない設計が重要です。
単に現在のファイルをQdrantへupsertするだけでは、次の問題が残ります。
- 同じcommitを何度も処理する
- 削除されたファイルのpointが残る
- リネーム前のpathが残る
- チャンク数が減ったとき、古いchunkが残る
- ingest失敗後にstateだけが進む
- 前回のcommitが取得できなくなり、差分を計算できない
- chunkingやembedding modelを変えても再構築されない
この記事では、GitのSHA、status付きdiff、決定的UUID、path単位の置換、成功後のstate更新を組み合わせて、再試行可能な差分インデックスを構成します。
スケジューラは問いません。launchd、CI、systemd timer、手動実行のどれから呼び出しても同じ考え方を使えます。
macOSでGitミラーを定期同期する構成については、次の記事で扱っています。
この設計は、同じrepositoryに対するingestが同時実行されないことを前提としています。
CI、手動実行、複数schedulerなどから起動する可能性がある場合は、scheduler側で同時実行を禁止するか、lockf(1)などで処理全体を排他してください。point単位の冪等性だけでは、複数実行間のdelete・upsert・state更新の競合は防げません。
TL;DR
- 対象refをSHAへ解決し、前回と同じなら何もしない
- stateはingest成功後にだけ更新する
- stateは一時ファイルからatomicに置き換える
-
git diff --name-status -zで追加・変更・削除・リネームを区別する - rename statusは
R100のような形式になるため、startsWith('R')で判定する - 前回のcommitが存在しない場合はfull reindexへfallbackする
- Qdrantのpoint IDは論理的な文書位置から決定的UUIDを生成する
- 更新前にpath単位で古いpointを削除し、チャンク数減少とファイル削除へ対応する
-
deleteとupsertは別operationなので、置換全体はatomicではない - chunkingやembedding modelの変更は、schema versionを使ってfull reindexを発火させる
この処理でいう冪等性
この記事での「冪等」は、同じ差分を再実行しても最終的なインデックス状態が同じになることを指します。
この収束性は、同じ入力から同じ順序のchunkを生成できることも前提としています。
ファイル列挙やchunkingの順序が非決定的な場合、chunkIndexから生成するpoint IDと内容の対応も実行ごとに変わります。
たとえば次のような失敗を想定します。
- Gitの更新を取得する
- 10ファイル中5ファイルを更新する
- 6ファイル目でembedding APIが失敗する
- processが終了する
- 次回、同じ差分を最初から再実行する
このとき、再実行によってpointが重複したり、削除済みのファイルが復活したりしない設計が必要です。
Gitミラーとstateを管理する
次のスクリプトは、mirrorを更新し、対象refのSHAを前回処理したSHAと比較します。
変更がある場合だけ、NUL区切りの差分ファイルを作成してingest.mjsへ渡します。
#!/bin/zsh
set -euo pipefail
readonly REPOSITORY_ID='knowledge-base'
readonly REPOSITORY_URL='https://github.com/example-org/knowledge-base.git'
readonly TARGET_REF='refs/heads/main'
readonly DATA_DIR='/Users/USERNAME/Library/Application Support/com.example.git-indexer'
readonly MIRROR_DIR="${DATA_DIR}/mirrors/knowledge-base.git"
readonly STATE_DIR="${DATA_DIR}/state"
readonly STATE_FILE="${STATE_DIR}/main.sha"
readonly INGEST_SCRIPT='/Users/USERNAME/.local/libexec/ingest.mjs'
readonly GIT_BIN='/usr/bin/git'
readonly NODE_BIN='/opt/homebrew/bin/node'
timestamp() {
/bin/date '+%Y-%m-%dT%H:%M:%S%z'
}
write_state() {
local new_sha="$1"
local temporary_state
temporary_state="$(/usr/bin/mktemp "${STATE_DIR}/state.XXXXXX")"
/usr/bin/printf '%s\n' "$new_sha" > "$temporary_state"
/bin/mv "$temporary_state" "$STATE_FILE"
}
/bin/mkdir -p "${DATA_DIR}/mirrors" "$STATE_DIR"
if [[ -d "$MIRROR_DIR" ]]; then
"$GIT_BIN" --git-dir="$MIRROR_DIR" remote update --prune
else
"$GIT_BIN" clone --mirror "$REPOSITORY_URL" "$MIRROR_DIR"
fi
empty_tree_sha="$(
"$GIT_BIN" --git-dir="$MIRROR_DIR" hash-object -t tree /dev/null
)"
new_sha="$(
"$GIT_BIN" \
--git-dir="$MIRROR_DIR" \
rev-parse "${TARGET_REF}^{commit}"
)"
old_sha=''
if [[ -f "$STATE_FILE" ]]; then
old_sha="$(<"$STATE_FILE")"
fi
if [[ "$new_sha" == "$old_sha" ]]; then
/usr/bin/printf '%s no change (%s), skip ingest\n' "$(timestamp)" "$new_sha"
exit 0
fi
changes_file="$(/usr/bin/mktemp "${STATE_DIR}/changes.XXXXXX")"
cleanup() {
/bin/rm -f "$changes_file"
}
trap cleanup EXIT
mode='incremental'
if [[ -z "$old_sha" ]]; then
mode='full'
"$GIT_BIN" \
--git-dir="$MIRROR_DIR" \
diff \
--name-status \
-z \
"$empty_tree_sha" \
"$new_sha" > "$changes_file"
elif "$GIT_BIN" \
--git-dir="$MIRROR_DIR" \
cat-file \
-e \
"${old_sha}^{commit}" 2>/dev/null; then
"$GIT_BIN" \
--git-dir="$MIRROR_DIR" \
diff \
--name-status \
-z \
--find-renames \
"$old_sha" \
"$new_sha" > "$changes_file"
else
mode='full'
/usr/bin/printf \
'%s previous commit is unavailable; fallback to full reindex\n' \
"$(timestamp)"
"$GIT_BIN" \
--git-dir="$MIRROR_DIR" \
diff \
--name-status \
-z \
"$empty_tree_sha" \
"$new_sha" > "$changes_file"
fi
if [[ ! -s "$changes_file" ]]; then
write_state "$new_sha"
/usr/bin/printf \
'%s ref moved without tree changes (%s)\n' \
"$(timestamp)" \
"$new_sha"
exit 0
fi
"$NODE_BIN" "$INGEST_SCRIPT" \
--repository-id "$REPOSITORY_ID" \
--git-dir "$MIRROR_DIR" \
--ref "$new_sha" \
--changes-file "$changes_file" \
--mode "$mode"
write_state "$new_sha"
/usr/bin/printf \
'%s ingest completed (%s, %s)\n' \
"$(timestamp)" \
"$mode" \
"$new_sha"
SHAをdebounceとして使う
前回処理したSHAと現在のSHAが同じ場合は、embedding生成やQdrantへの書き込みを行いません。
if [[ "$new_sha" == "$old_sha" ]]; then
exit 0
fi
これはschedulerのdebounceではなく、入力状態によるdebounceです。
実行間隔を1分にしても1時間にしても、Gitの対象refが変わらない限り後続処理は起動しません。
stateは成功後にだけ進める
stateファイルの更新は、Node.js側のingestが正常終了した後です。
"$NODE_BIN" "$INGEST_SCRIPT" ...
write_state "$new_sha"
set -eが有効なため、ingestがnon-zeroで終了するとwrite_stateには到達しません。
古いSHAが残るので、次回実行時には同じ差分が再試行されます。
stateをatomicに置き換える
stateファイルへ直接書き込むと、process停止のタイミングによっては、空ファイルや不完全な値のみ残るおそれがあります。
temporary_state="$(mktemp "${STATE_DIR}/state.XXXXXX")"
printf '%s\n' "$new_sha" > "$temporary_state"
mv "$temporary_state" "$STATE_FILE"
一時ファイルを同じディレクトリに作り、最後にmvすることで、同一ファイルシステム上では実質的にatomicな置き換えになります。
空treeのSHAを固定値にしない
SHA-1リポジトリでよく使われるempty treeの値を定数で埋め込むと、SHA-256形式のリポジトリへ対応できません。
empty_tree_sha="$(
git --git-dir="$MIRROR_DIR" hash-object -t tree /dev/null
)"
mirror自身に生成させれば、repositoryのobject formatへ追従できます。
初回実行では、このempty treeと現在のcommitを比較することで、全ファイルをAとして取得できます。
--name-status -zで変更種別を保持する
git diffの--name-onlyでは、pathしか分かりません。
削除やリネームを正しく反映するには、statusも必要です。
git diff \
--name-status \
-z \
--find-renames \
"$old_sha" \
"$new_sha"
最低限、次のstatusを処理します。
-
A: 追加 -
M: 変更 -
D: 削除 -
R<score>(例:R100): リネーム
renameはRだけではなく、類似度を含むR100、R087のような形式になります。
Node.js側では、次のように判定します。
if (status.startsWith('R')) {
// old pathを削除し、new pathを追加する
}
-z形式では、通常の変更はstatus\0path\0ですが、renameはR100\0old-path\0new-path\0のように3フィールドを消費します。
単純に2要素ずつ読み進めるparserでは、rename以降の境界がずれるため注意してください。
-zを付ける理由は、空白・改行・特殊文字を含むファイル名を壊さないためです。
改行区切りのreadlineやsplit('\n')では安全に解析できません。
前回のcommitが存在しない場合
mirrorをremote update --pruneすると、履歴の書き換えや参照削除により、stateに保存したcommitを取得できなくなる場合があります。
git --git-dir="$MIRROR_DIR" cat-file -e "${old_sha}^{commit}"
前回commitが存在しない場合は、差分更新を諦めてfull reindexへfallbackします。
正確性を優先し、部分的に推測して更新しない方が安全です。
full reindexを発火させる条件
このスクリプトが自動的にfull modeへ入るのは次の場合です。
- 初回実行でstateが存在しない
- stateに保存されたcommitがmirror内に存在しない
一方、次の変更はGitのSHAだけでは検出できません。
- chunking algorithmの変更
- chunk sizeの変更
- embedding modelの変更
- payload schemaの変更
- metadata抽出方法の変更
これらを変更した場合は、stateファイルを削除するか、インデックスschema versionをstateへ含めます。
たとえば次のようなstateを保存できます。
{
"gitSha": "0123456789abcdef",
"indexSchemaVersion": 3
}
上記のスクリプトはSHAのみをmain.shaへ保存しています。schema versionを利用する場合は、stateファイルをJSON化し、読み書きする処理も合わせて変更してください。
期待するversionと一致しない場合はfull reindexを実行します。
Qdrantのpoint IDを決定的にする
Qdrantのpoint管理ドキュメントでは、point IDとして64-bit unsigned integerまたはUUIDを利用できます。
ランダムUUIDを生成すると、同じchunkを再処理するたびに別pointとして追加されます。
論理的な文書位置からUUID v5を生成します。
import { v5 as createUuidV5 } from 'uuid'
const createPointId = (
repositoryId: string,
path: string,
chunkIndex: number,
): string =>
createUuidV5(
`${repositoryId}:${path}:${chunkIndex}`,
createUuidV5.URL,
)
commit SHAはpoint IDへ含めず、payloadへ保存します。
payload: {
path,
repository_id: repositoryId,
sha: newSha,
}
point IDにcommit SHAを含めると、更新のたびに新しいIDが生成され、古いversionが残ります。
決定的IDだけでは足りない
以前は10チャンクだったファイルが、更新後に6チャンクへ減ったとします。
0〜5を同じIDでupsertしても、古い6〜9は残ります。
同様に、ファイルを削除してもupsertだけでは既存pointが消えません。
そのため、path単位で既存pointを削除してから、現在のchunkを再投入します。
安全な流れは次のとおりです。
-
repository_id + pathで既存pointを削除する - 削除されたファイルなら終了する
- 現在のファイルをチャンク化する
- 決定的UUIDを生成する
- 新しいpointをupsertする
- 全ファイルの処理成功後にGitのstateを進める
以下はingest.mjsのうち、Qdrantのpath単位置換に関する抜粋です。
--name-status -zの解析、Git blobの取得、チャンク化、embedding生成、--mode full時のrepository全体の削除・再投入処理は省略しています。
import { QdrantClient } from '@qdrant/js-client-rest'
import { v5 as createUuidV5 } from 'uuid'
type IndexedChunk = {
embedding: number[]
path: string
text: string
}
type ReplacePathInput = {
chunks: IndexedChunk[]
collectionName: string
newSha: string
path: string
repositoryId: string
}
const client = new QdrantClient({
url: 'http://127.0.0.1:6333',
})
const createPointId = (
repositoryId: string,
path: string,
chunkIndex: number,
): string =>
createUuidV5(
`${repositoryId}:${path}:${chunkIndex}`,
createUuidV5.URL,
)
const createPathFilter = (repositoryId: string, path: string) => ({
must: [
{
key: 'repository_id',
match: {
value: repositoryId,
},
},
{
key: 'path',
match: {
value: path,
},
},
],
})
const deleteIndexedPath = async (
collectionName: string,
repositoryId: string,
path: string,
): Promise<void> => {
await client.delete(collectionName, {
filter: createPathFilter(repositoryId, path),
wait: true,
})
}
const replaceIndexedPath = async ({
chunks,
collectionName,
newSha,
path,
repositoryId,
}: ReplacePathInput): Promise<void> => {
await deleteIndexedPath(collectionName, repositoryId, path)
if (chunks.length === 0) {
return
}
await client.upsert(collectionName, {
wait: true,
points: chunks.map((chunk, chunkIndex) => ({
id: createPointId(repositoryId, path, chunkIndex),
vector: chunk.embedding,
payload: {
chunk_index: chunkIndex,
path,
repository_id: repositoryId,
sha: newSha,
text: chunk.text,
},
})),
})
}
ファイル削除とリネーム
ファイル削除
Dを受け取った場合は、pathに一致するpointを削除するだけです。
新しいblobを取得しようとしてはいけません。
リネーム
R<score>を受け取った場合は、2つのpathが返ります。
- 旧pathのpointを削除する
- 新pathのblobを取得する
- 新pathとしてチャンク化・upsertする
point IDにpathを含めているため、renameは単なる内容更新ではなく、旧ID群の削除と新ID群の作成になります。
deleteとupsertはatomicではない
この例は再試行可能ですが、deleteとupsertを別々のリクエストで実行するため、置換全体はatomicではありません。
delete成功後にupsertが失敗すると、次回の再試行まで対象pathのpointが欠落します。
ローカル検索や更新中の短時間欠落を許容できる用途では、単純で扱いやすい構成です。
検索可用性が必要な環境では、次のような構成を検討します。
- 新しいcollectionへ次世代のindexを構築する
- 構築完了後にcollection aliasを切り替える
- 古いcollectionを削除する
Qdrantのcollection aliasはatomicに切り替えられるため、世代単位の再構築に向いています。
Qdrant Documentation: Collection aliases
payload indexを作る
repository_idとpathで頻繁にfilterする場合は、payload indexを作成します。
-
repository_id: keyword -
path: keyword
これにより、削除filterの性能とcardinality推定を改善できます。
Qdrant Documentation: Indexing
この設計で再試行するとどうなるか
途中で失敗し、同じ差分を再実行した場合の挙動は次のとおりです。
- すでに更新済みのpathは、既存pointを削除して同じUUIDで再作成される
- 未処理のpathは通常どおり処理される
- 削除済みファイルは、同じfilter削除を再実行しても問題ない
- rename元のpathは、再度削除しても問題ない
- stateは最後まで成功しない限り進まない
つまり、途中まで進んだかどうかにかかわらず、同じ入力から同じ最終状態へ収束します。
よくある失敗
point IDへランダムUUIDを使う
再実行のたびに重複pointが増えます。
point IDへcommit SHAを含める
更新のたびに別versionとして追加されます。最新状態だけを検索したい場合には不向きです。
upsertだけで削除も反映できると思う
消えたファイルや減ったchunkは自動では削除されません。
git diff --name-onlyだけを使う
削除とrenameを区別できません。
rename statusをRと完全一致で比較する
実際にはR100のようにsimilarity scoreを含みます。
stateを処理開始時に更新する
失敗後に差分を再試行できなくなります。
chunkingやembedding modelを変えても同じstateを使う
GitのSHAに変更がなければ処理をskipするため、古いindexは更新されません。
まとめ
差分インデックスの冪等性は、決定的IDだけでは成立しません。
必要なのは、複数の層を同時に揃えることです。
-
入力層
Gitの対象refをSHAへ解決し、同じSHAなら何もしない -
差分層
--name-status -zで追加・変更・削除・renameを保持する -
データ層
path単位で古いpointを削除し、現在のchunkだけを再投入する -
ID層
repository・path・chunk indexから決定的UUIDを生成する -
state層
全処理成功後にだけstateをatomicに更新する -
version層
chunkingやembedding modelの変更をschema versionで検出する
この構成なら、同じ差分を何度再実行しても、point ID・path・payloadを同じ論理状態へ収束させられます。