opencode-config/docs/decisions/036-pr-83-incremental-index-sha256-atomic.md
Sergey 25cf10baa6
feat(memory): incremental index with SHA256 + atomic + versioning + flock (#83)
* 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>
2026-07-26 19:19:55 +03:00

5.7 KiB
Raw Permalink Blame History

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. Расход бюджета: 1 save = полная переиндексация всех 2354 чанков (340× больше необходимого — нужны только изменившиеся ~10 чанков).
  2. Скорость: 13 минут блокирует каждый memory_save. Должно быть ~1 сек.
  3. Не 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:

  1. 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.

  2. Atomic writes (index.py): _atomic_write(path, content) — write .tmpos.replace (атомарная замена на POSIX). Используется для index.json и meta.json. При сбое процесса mid-write — старый файл остаётся целым, .tmp остаётся мусором (можно очистить при следующем запуске).

  3. 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.

  4. 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).

  5. Rate-limit-aware batching (embedder.py): при 429 читать Retry-After header → time.sleep(int(retry_after)) → raise (tenacity поймает и retry'нет). stop_after_attempt(3)stop_after_attempt(5), wait_exponential(max=10)wait_exponential(max=30).

  6. Прогресс-лог (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_SIZE default с 2048 до 50: отвергнуто — default 2048 для OpenAI direct (поддерживает большой batch), OpenRouter требует 50 через env var OPENAI_EMBEDDING_BATCH_SIZE=50. .env.example обновлён с OpenRouter defaults, но код default не изменён.