opencode-config/docs/decisions/042-pr-100-lazy-init-embedder.md
Sergey a6491df5a6
refactor(memory): lazy-init embedder + reindex logging (#100)
* refactor(memory): lazy-init embedder env reads

* feat(memory): reindex log to .rag/reindex.log

* refactor(memory): search handles None embedder

* test(memory): cover None embedder in search and index

* docs(handoff): add handoff and ADR for lazy-init embedder

* docs(handoff): set PR number

* docs(handoff): rename to pr-100 prefix for pipeline detection

* refactor(memory): split run_search to satisfy xenon rank A

* fix(memory): cast search result fields to satisfy mypy

* style(memory): ruff format search.py

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-27 00:30:16 +03:00

3.9 KiB
Raw Blame History

ADR-042: lazy-init embedder + reindex logging

Статус

Accepted (2026-07-26)

Контекст

src/memory/embedder.py падал с RuntimeError("OPENAI_BASE_URL env var not set") при импорте если env var не задана. Любой модуль, импортирующий embedder (index.py:8, search.py:7), падал ещё до вызова функции. TS tools, вызывающие Python через spawnSync("python3", [...]), получали traceback и падали целиком — memory backend был неработоспособен без OpenRouter credentials.

Логирование reindex отсутствовало: при таймауте/ошибке процесс молча умирал без диагностики (см. technical/openrouter-qwen3-embedding-index-timeout.md — 120с процесса timeout при batch=50 × 47 батчей × ~16с = ~750с, процесс молча завершается без вывода).

Это блокирует миграцию к единой облачной памяти с keyword fallback (issue #94): backend должен работать всегда, деградируя к keyword при отсутствии embeddings, а не падать.

Решение

  1. Lazy-init embedder: API_URL/API_KEY читаются внутри embed_texts() при вызове, не при импорте. При отсутствии OPENAI_BASE_URLreturn None (не raise). При API error после 5 retries → return None (catch httpx.HTTPError, RetryError, ValueError, KeyError). Сигнатура: embed_texts(texts: list[str]) -> list[list[float]] | None. EMBEDDING_MODEL/BATCH_SIZE остались module-level с безопасными дефолтами ("gemini-embedding-2-preview", "2048") — не падают без env.

  2. Reindex logging: src/memory/index.py пишет лог в ${output_dir}/reindex.log (append mode, ISO-8601 UTC timestamps). Формат: 2026-07-26T20:15:30Z start: changed=3 total=82. Логирует start, per-batch (model, status), done (total, took, changed/unchanged/deleted), failed (embeddings unavailable). Per-batch вызовы embed_texts вместо одного bulk-вызова — позволяет логировать прогресс каждого batch.

  3. None handling в callers: search.py возвращает [] если embed_texts → None. index.py логирует "failed: embeddings unavailable" и выходит с 0 (не пишет index.json). Caller (TS tool) делает fallback на keyword search.

Альтернативы

  • reraise=True в @retry — tenacity пробрасывает последнюю exception вместо оборачивания в RetryError. Отклонено: потребовало бы ловить конкретные httpx exceptions по отдельности (HTTPStatusError, ConnectError, TimeoutException), хрупче. Catch RetryError + httpx.HTTPError покрывает все случаи.
  • Keyword fallback внутри Python (search.py) — если embedder None, делать ripgrep в search.py. Отклонено: keyword fallback уже реализован в TS tool (plugin scoring logic), дублирование не нужно. Python возвращает [] — TS tool решает fallback.
  • Лог в memory root (reindex.log рядом с .md) — отклонено: .rag/ уже gitignored в памяти, лог рядом с memory files засоряет директорию и попадает в индексацию. Лог в .rag/ изолирован.
  • logging module вместо ручного _log_line — отклонено: logging требует config (handlers, formatters), избыточно для одного файла. _log_line = 4 строки, append mode, явный timestamp.