* feat(memory): add SHA256 incremental index with atomic writes, versioning, flock * feat(memory): parse Retry-After header and add batch progress logging * test(memory): add incremental, atomic, versioning, retry, batch tests * docs(memory): update .env.example with OpenRouter defaults * docs(handoff): add handoff and ADR-036 for incremental index * docs(handoff): set PR number * docs(project-map): update index.py and embedder.py descriptions for PR#83 * fix(ci): skip index rewrite on no-op + versioning first-run * fix(ci): ruff format index.py --------- Co-authored-by: opencode-agent <agent@opencode.local>
5.7 KiB
ADR-036: Incremental RAG index with SHA256, atomic writes, versioning, flock
Статус
Accepted (2026-07-26)
Контекст
PR #75 (refactor) + PR #77 (plugin wrapper) сделали Memory CLI рабочим через OpenRouter Qwen3 8B: .rag/index.json создан (2354 records, dim=4096, 218 MB), memory_search возвращает semantic hits. Но run_index делал полный переиндекс при каждом запуске: 2354 embeddings per save, ~13 минут на OpenRouter Qwen3 8B, расход $0.0043/save (при бюджете $9 = 2076 reindexes = 5.7 лет при 1 save/день). Smoke-test (technical/openrouter-qwen3-embedding-index-timeout.md) подтвердил: 47 батчей × 16.5с = 752с, команда с timeout 120 молча убивала процесс до первого print.
Три проблемы:
- Расход бюджета: 1 save = полная переиндексация всех 2354 чанков (340× больше необходимого — нужны только изменившиеся ~10 чанков).
- Скорость: 13 минут блокирует каждый
memory_save. Должно быть ~1 сек. - Не production-grade: нет atomic writes (упал процесс → битый index.json), нет versioning (смена модели → несовместимые embeddings в одном индексе), нет concurrent safety (два
memory index→ race на meta.json), нет rate-limit-aware retry (OpenRouter 429 без Retry-After parsing).
Решение
Четыре независимых улучшения в src/memory/index.py + src/memory/embedder.py:
-
SHA256 инкрементальный индекс (
index.py):_content_hash(text: str) -> str(SHA256 от extracted text, не от raw file — frontmatter не влияет на embedding)..rag/meta.jsonхранит{version, files: {path: {sha256, chunks}}}.run_index: rglob *.md → SHA256 extracted text → сравнить с meta.json →changed_files(хеш не совпал или файла нет) →embed_texts(только changed chunks)→ merge old (unchanged) + new (changed) → prune удалённых файлов. Первый запуск (meta.json не существует) → полный reindex. -
Atomic writes (
index.py):_atomic_write(path, content)— write.tmp→os.replace(атомарная замена на POSIX). Используется дляindex.jsonиmeta.json. При сбое процесса mid-write — старый файл остаётся целым,.tmpостаётся мусором (можно очистить при следующем запуске). -
Index versioning (
index.py):version = f"{EMBEDDING_MODEL}:{chunk_size}:{chunk_overlap}". При load meta.json: еслиmeta["version"] != current_version→ полный reindex (лог "Index version mismatch, full reindex"). Защищает от несовместимых embeddings при смене модели/chunking. -
Concurrent safety (
index.py):fcntl.flock(LOCK_EX)на.rag/.lockво время всей index операции.output_dir.mkdir(parents=True, exist_ok=True)ДО создания lock. Search НЕ блокирует (читает index.json без lock — допускает stale reads, OK для RAG). -
Rate-limit-aware batching (
embedder.py): при 429 читатьRetry-Afterheader →time.sleep(int(retry_after))→ raise (tenacity поймает и retry'нет).stop_after_attempt(3)→stop_after_attempt(5),wait_exponential(max=10)→wait_exponential(max=30). -
Прогресс-лог (
index.py+embedder.py):print(f"Embedding N chunks in M batches...", flush=True)передembed_texts;print(f" batch K/N...", flush=True)в loop (только если > 1 batch). 13 мин полного reindex не выглядят как hang.
Альтернативы
- mtime-based incremental (вместо SHA256): отвергнуто —
touchбез изменения content триггерит re-embed (mtime изменился, content нет). SHA256 от extracted text точнее (frontmatter changes не триггерят re-embed, mtime файла меняется). - file-level hash (вместо chunk-level): отвергнуто —_chunks в рамках файла независимы, но hash файла достаточно (при изменении файла re-embed всех его чанков — проще и достаточно для memory use case где файлы маленькие).
- transactional index (write-ahead log, 2-phase commit): отвергнуто — overkill для single-process CLI. Atomic write через
os.replaceдостаточно на POSIX. - file locking через
portalocker/fasteners: отвергнуто —fcntlстандартная библиотека, POSIX-only (Memory CLI предназначен для Linux/Docker), cross-platform не требуется. - embedding cache (cache по hash текста на disk): отвергнуто — out of scope, отдельный PR. Текущая инкрементальность на уровне файлов уже даёт 340× экономию.
- увеличение
BATCH_SIZEdefault с 2048 до 50: отвергнуто — default 2048 для OpenAI direct (поддерживает большой batch), OpenRouter требует 50 через env varOPENAI_EMBEDDING_BATCH_SIZE=50..env.exampleобновлён с OpenRouter defaults, но код default не изменён.