Files
nanobot-runtime/plans/final-wiki-hybrid-rag.md
2026-09-10 12:33:37 +02:00

494 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Final Wiki — hybrid RAG index nad poznámkami
**Stav:** připraveno k implementaci. **Vznik:** 2026-09-09.
**Nahrazuje** draft `notes-search-hybrid-rag.md` (server, 2026-09-09) — ten je superseded.
Veškerá čísla v tomto dokumentu jsou **naměřená na reálném prostředí**
(`nanobot.hell``nvidia.hell`, 2026-09-09), ne odhadnutá. Kde jde o odhad, je to napsané.
## Cíl
Uživatel vede poznámky ve vlastních git repozitářích (adresářová struktura, md soubory; témata
devops, traveling, mix). Nanobot dostane **read-only přístup** k mirrorům těchto repů a postaví
nad nimi index pro rychlé hledání — tematické i syntaktické. Druhým zdrojem je **nanobot
workspace** (živá data).
Poznámky zůstávají kanonickými daty v gitu; **index je derived artifact, regenerovatelný**.
Do uživatelových repů se nikdy nezapisuje.
Skill je **plně samostatný** — žádná závislost na jiném nanobot skillu.
## Ověřená fakta o prostředí
| Co | Naměřeno |
|---|---|
| Ollama endpoint `http://nvidia.hell:11434` | dosažitelný z nanobota |
| `qwen3-embedding:0.6b` | nainstalován; 595,78M params, **1024 dims**, capability `embedding` |
| Cold load modelu / warm | **1,81 s** / **0,043 s** |
| Embed throughput | 8,9 chunk/s po jednom → **108 chunk/s** v batchi 32 (12×) |
| `keep_alive` tvar | číslo `-1` i `"24h"` → 200; **string `"-1"` → HTTP 400** |
| Ollama **Cloud** embeddingy | **neexistují** — 18 cloud modelů, žádný s capability `embedding`; `/api/embed` na cloud modelu vrací `unauthorized`, zatímco `/api/generate` na tomtéž projde |
| SQLite | 3.46.1; FTS5 `unicode61`/`trigram`/`porter`; **`enable_load_extension` funguje** |
| `sqlite-vec` | **v0.1.6**, `vec0(float[1024] distance_metric=cosine)` v témže souboru jako `chunks`+FTS5 |
| `vec0` KNN k=20 | **19 ms @ 10k** chunků, **212 ms @ 100k** (BLOB+numpy: 594 ms) |
| `vec0` zápis | 10k = 2,1 s; 100k = 25,3 s; soubor 466 MB (BLOB 461 MB — bez režie) |
| `vec0` mutace | `DELETE`/re-`INSERT`/`UPDATE` po `rowid` **fungují**; `ROLLBACK` je transakční |
| BLOB+numpy rozpad @ 100k | read 382 ms + pack 190 ms + **dot 22 ms** → 96 % je Python režie |
| FTS5 čeština | `remove_diacritics 1`+ foldí (`zaloha` najde `záloha`); `záloh*` najde *záloha/zálohování/zálohy* |
| Reálný korpus workspace | 234 md mimo `tmp/` (1,28 MB) → **~800 chunků**; z toho použitelných ~99 souborů |
| `exec` tool timeout | **60 s** |
| `db/` ve workspace | gitignorováno → index je automaticky mimo git |
**Retrieval kvalita — měřeno na 178 chunkách reálného obsahu:**
| Dotaz | BM25 | embeddingy |
|---|---|---|
| „prodloužení životnosti LEGO" | 4/5 | **5/5** |
| „jak snížit elektroodpad ze stavebnic" | **1/5** | **3/5** |
Druhý řádek je přesně ten parafrázový/cross-jazyk případ, pro který tu hybrid je: BM25 selže,
vektory najdou. První řádek je opačný — lexikálně snadný dotaz zvládne BM25. **Obě poloviny
si vydělávají**, což potvrzuje D1 měřením, ne argumentem.
## Architektura
Klíčová vlastnost: **`chunks` je jediná retrieval jednotka.** RRF slučuje dva ranky *téže*
množiny — kdyby BM25 řadil soubory a vektory chunky, merge by neměl definovaný význam.
```
Zdroje:
git mirrory (RO clone/fetch, keyed na indexed-rev)
nanobot workspace (walk path+size+mtime)
↓ ingest driver (git-diff / fs-scan — dva drivery, jeden indexer)
↓ chunker (md → chunky po heading struktuře, markdown-it-py)
↓ embeddings: Ollama /api/embed (qwen3-embedding:0.6b), batch 32, jen změněné chunky
db/notes-index.sqlite:
chunks (kanonické: metadata + text + stav embeddingu)
├── chunks_fts FTS5 external-content → BM25 rank nad chunks.id
└── vec_chunks vec0 virtual table → KNN rank nad chunks.id (rowid = chunks.id)
RRF merge nad chunks.id
```
Tenhle tvar drží `vec0` **vyměnitelné**: kdyby pre-v1 breaking change zabolel, přidá se
`embedding BLOB` zpět do `chunks` a nic jiného se nemění.
### Schéma
```sql
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
-- embedding_model, embedding_dims, normalized, query_prefix,
-- chunker_version, schema_version, sqlite_vec_version
CREATE TABLE IF NOT EXISTS sources (
source_id TEXT PRIMARY KEY, -- klíč z YAML, stabilní
kind TEXT NOT NULL CHECK(kind IN ('git','workspace')),
indexed_rev TEXT, -- jen git driver
last_sync_at TEXT
);
CREATE TABLE IF NOT EXISTS files (
source_id TEXT NOT NULL REFERENCES sources(source_id),
path TEXT NOT NULL, -- relativní ke zdroji
title TEXT, tags TEXT, headings TEXT, -- tags/headings jako JSON array
sha256 TEXT NOT NULL,
size INTEGER NOT NULL,
mtime REAL, -- rychlý pre-filter workspace driveru
indexed_at TEXT NOT NULL,
PRIMARY KEY (source_id, path)
);
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id TEXT NOT NULL,
path TEXT NOT NULL,
chunk_idx INTEGER NOT NULL,
breadcrumb TEXT NOT NULL, -- "soubor > sekce > podsekce"
text TEXT NOT NULL,
embedded_at TEXT, -- NULL = čeká na vektor (degradovaný režim)
UNIQUE (source_id, path, chunk_idx),
FOREIGN KEY (source_id, path) REFERENCES files(source_id, path) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_chunks_pending ON chunks(id) WHERE embedded_at IS NULL;
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
breadcrumb, text,
content='chunks', content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
);
CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
INSERT INTO chunks_fts(rowid, breadcrumb, text) VALUES (new.id, new.breadcrumb, new.text);
END;
CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
INSERT INTO chunks_fts(chunks_fts, rowid, breadcrumb, text)
VALUES('delete', old.id, old.breadcrumb, old.text);
END;
CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN
INSERT INTO chunks_fts(chunks_fts, rowid, breadcrumb, text)
VALUES('delete', old.id, old.breadcrumb, old.text);
INSERT INTO chunks_fts(rowid, breadcrumb, text) VALUES (new.id, new.breadcrumb, new.text);
END;
CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(
embedding float[1024] distance_metric=cosine
);
```
Poznámky ke schématu, všechny ověřené smoke testem:
- **Klíč je `(source_id, path, chunk_idx)`**, nikdy jen `path``DELETE WHERE path = ?` by
mazalo chunky cizího zdroje.
- **Stav embeddingu je sloupec `chunks.embedded_at`**, ne absence řádku ve `vec_chunks`.
U `vec0` se „chybějící vektor" dotazuje blbě, a degradovaný režim potřebuje levný
`WHERE embedded_at IS NULL` (proto ten partial index).
- **PAST: `ON DELETE CASCADE` uklidí `chunks` i `chunks_fts` (trigger se na kaskádě spustí),
ale řádek ve `vec_chunks` osiří** — `vec0` není cílem foreign key. Indexer **musí** mazat
`vec_chunks` explicitně. Patří to do regresního testu.
- Vektory se ukládají **L2-normalizované** (`meta.normalized`), takže cosine == dot product.
- `sqlite3.connect(path, isolation_level=None)` — autocommit, transakce řízené explicitně.
Bez toho `BEGIN` spadne na „cannot start a transaction within a transaction".
## Chunking
Strukturně vědomý, po heading hierarchii — uživatelovy poznámky jsou md se smysluplnou
strukturou (adresáře = témata, soubory, H1H3 sekce), takže slepování přes hranice sekcí by
embeddingy kazilo.
- Rozdělení po heading sekcích (H1H3); každý chunk nese **breadcrumb** (`soubor > sekce >
podsekce`) v metadatech **i v embedding textu** — vektor tak nese kontext, ne izolovaný odstavec.
- Merge malých sousedních sekcí pod stejným rodičem (< ~200 tokenů).
- Split velkých sekcí (> ~800 tokenů) po odstavcích s overlap ~5080 tokenů.
Cílová granularita ~200800 tokenů/chunk.
- Kód bloky a tabulky se nerozbíjejí; checklistové soubory chunkujeme po bullet blocích.
- Frontmatter tagy jdou do `files.tags` (na filtrování), do embedding textu jde jen title.
- Token counting: aproximace ~4 znaky/token (přesný tokenizer pro sizing netřeba).
Parser je **markdown-it-py** (zná CommonMark edge cases — setext headings, nested listy,
HTML bloky), **chunkovací politika je vlastní** (~50 řádků nad tokeny).
## Hledání — tři vrstvy, od nejlevnější
1. **Syntakticky — live grep** (ripgrep přes mirror): exact match, názvy souborů, hostname,
tagy, čísla. Žádný index, vždy aktuální. Základ, ne fallback.
2. **Tematicky — generovaný TOC** (`toc.md` per zdroj: kategorie → soubor → jedna řádka;
title + tagy + headings). Index-first navigace, generovaná syncem.
3. **Fuzzy/sémanticky — hybrid**: FTS5 (BM25) + `vec0` (KNN), merge přes RRF.
### Query kontrakt
- **Instruct prefix**: dotaz embedovat jako `Instruct: <task>\nQuery: <text>`, dokumenty bez
prefixu (Qwen3-Embedding je asymetrický instruct-tuned model; Ollama prefix nepřidá).
**Měření ale ukázalo, že to není kritická vlastnost**: recall@5 byl s prefixem i bez něj
identický (5/5 vs 5/5, 3/5 vs 3/5) a absolutní similarita s prefixem dokonce nižší.
Prefix zlepšil jen **top-1 na nejtěžším dotazu** (`e-waste-reduction.md` místo
`software-preservation.md`). Zavádíme ho, protože je zdarma a na hraničním dotazu pomohl —
a hlavně proto, že **musí být bitově identický při indexaci i dotazu**, což je skutečný
důvod, proč hodnota žije v `meta.query_prefix`.
- **FTS5 termy s prefix wildcardem** (`záloh*`) — pokrývá českou flexi, kterou `unicode61`
nestemuje. Ověřeno: `záloh*` i `zaloh*` najdou *záloha/zálohování/zálohy*.
- **RRF** se dělá v Pythonu nad dvěma seznamy `chunks.id` (`vec0` KNN vyžaduje `k`).
- **Mismatch `meta` vs. config** (jiný model/dims/prefix/chunker_version) → **dotaz odmítnout**
s „reindex needed". Nikdy tiše nemíchat vektory ze dvou modelů.
- **Ollama nedostupná** → FTS-only a **říct to ve výstupu**, ne tiše degradovat.
## Provozní model — offline sync
**Indexace nikdy neběží v tahu agenta.** `exec` má timeout 60 s a plný index ~10⁴ chunků při
108 chunk/s je ~77 s. Agent v tahu jen **čte** hotový index.
```
cron: * * * * * (vzor: existující remind_send / note_compile řádky; PATH v hlavičce crontabu)
notes_sync.py
1. lock: db/.notes-sync.lock (O_EXCL). Držený → exit 0 bez výpisu. Žádný souběh.
2. levná detekce změn, BEZ indexace:
git zdroje: git ls-remote <url> HEAD vs. sources.indexed_rev (síť, ne fetch)
workspace zdroje: walk + (path, size, mtime) vs. files (sha256 jen na mismatch)
3. nic se nezměnilo → exit 0 (běžný případ, drtivá většina tiků)
4. změněné soubory: chunk → embed (batch 32, keep_alive -1) → upsert v transakci
smazaný/přejmenovaný soubor: DELETE z files (kaskáda uklidí chunks+FTS)
+ EXPLICITNÍ DELETE z vec_chunks
5. update sources.indexed_rev / last_sync_at
6. coverage report: zaloguj top-level adresáře s *.md, které nepokrývá žádný source
7. log do log/notes_sync.log
```
- `git ls-remote` je pro minutovou kadenci správný nástroj — zjistí remote HEAD **bez** `fetch`.
`fetch` teprve když se rev liší.
- **Idempotentní**: re-run nad stejnou revizí = no-op.
- Lock řeší souběh sám, žádná externí orchestrace.
- **Plný re-index** = tentýž skript s `--full`. Vzácná operace (změna modelu nebo chunkeru).
- `keep_alive: -1` na embed requestech drží model resident. Není kritické (cold load je 1,81 s),
ale je to zdarma. Pozn.: pod tlakem na VRAM od velkých chat modelů (gemma4:12b má 7,5 GB)
může scheduler potřebovat místo — při 639 MB je to nepravděpodobné, ale garance to není.
## Rozsah indexace — whitelist primárně, blacklist jako skalpel
**Precedence:** `paths` (whitelist — co v něm není, pro index neexistuje) → `include`
(whitelist přípon, default `*.md`) → `exclude` (skalpel, vyhrává nad oběma).
**Proč whitelist ve workspace — směr selhání.** U blacklistu nový adresář *tiše vstoupí* do
indexu, u whitelistu *tiše chybí*. Embedding tabulka je de facto komprimovaná kopie obsahu
(včetně osobních věcí), takže „tiše zaindexováno" je horší porucha. A workspace se mění
autonomně — Dream přepisuje soubory, skilly appendují, cron zapisuje.
**Doloženo měřením:** ve workspace je 234 md mimo `tmp/` a použitelných je ~99. Zbytek je
`cml/` 39, `skills/` 32, `.venv/` 27, `backup/` 20, `tasks/` 16, `.pytest_cache` 1.
A `tmp/` drží **dalších 135 md** (git klony, 5× reflect dump po ~200 kB). Blacklist by musel
hned první den správně pokrýt pět a půl adresáře a zůstat správný navždy — a nejhorší z nich
je právě ten, který je určený k tomu, aby se v něm hromadil balast.
**Proč opt-out u git rep.** Uživatelova poznámková repa jsou kurátorovaná a homogenní;
vyjmenovávat v nich podadresáře je zbytečná friction. Tam `paths: ["**"]` a malý `exclude`.
### Co se indexuje
| Zdroj | Cesty |
|---|---|
| `workspace` | `notes/**`, `projects/**`, `plans/**`, `knowledge/**`, `results/**`, `cook/**` |
| `develop` | `develop/**` mimo `develop/history.md` |
| git zdroje | celé repo, `*.md` |
`results/` (21 souborů, 256 kB výstupů deep-research) a `develop/knowledge.md` (113 kB hutných
ověřených faktů o instanci) jsou vědomé **přírůstky** — whitelist z nich dělá rozhodnutí.
### Co se neindexuje a proč
| Cesta | Důvod |
|---|---|
| `tmp/` (135 md) | git klony + reflect dumpy po 200 kB; adresář určený k balastu |
| `.venv/` (27), `.pytest_cache/`, `.ruff_cache/` | dokumentace balíčků a cache |
| `backup/` (20) | **near-duplicate kopie indexovaného obsahu** — otrávily by top-k redundantními hity; to je horší porucha než chybějící dokument |
| `develop/history.md` (331 kB) | append-only deník; ~200 chunků repetitivní narativy = ~25 % indexu při nízké hustotě signálu. `develop/knowledge.md` vedle něj zůstává |
| `skills/**` (32) | instrukce pro agenta, ne znalosti; agent si skilly načítá sám |
| `cml/` (39) | llm-wiki, ruší se mimo tento plán |
| root `AGENTS.md`/`SOUL.md`/`USER.md`/`keep.md`/`HEARTBEAT.md`, `memory/MEMORY.md` | vždy v kontextu nebo triviálně krátké → čistý šum |
| `log/`, `sessions/`, `db/`, `cron/`, `tasks/` | provozní stav, ne obsah |
| `memory/history.jsonl`, binárky | vyřazuje už `include: ["*.md"]` — do `exclude` psát netřeba |
**Záchranná síť proti jediné slabině whitelistu:** sync na konci zaloguje top-level adresáře,
které obsahují `*.md` a nepokrývá je žádný source. Tím se „tiše chybí" změní z neviditelné
poruchy na řádek v `log/notes_sync.log`.
## Konfigurace
`config/notes-search.yaml`. **Klíč sekce = source id** (stabilní; visí na něm katalog i vektory;
URL/path se můžou změnit, klíč ne; rename = explicitní invalidace indexu daného zdroje).
Source id nesmí kolidovat mezi sekcemi — katalog je sdílený přes `source_id`.
Formát je **YAML** (ne TOML — zamítnuto uživatelem, zapsáno v `USER.md`): config se edituje
ručně a komentáře v něm mají hodnotu, což JSON neumí.
```yaml
embedding:
endpoint: http://nvidia.hell:11434
model: qwen3-embedding:0.6b # tag psát VŽDY explicitně (latest = 8b, 4,7 GB)
dims: 1024
batch: 32
keep_alive: -1 # číslo, ne string ("-1" vrací HTTP 400)
query_prefix: "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery: "
sources:
git:
travel:
url: git@host:travel-notes.git
mirror: tmp/mirrors/travel.git
paths: ["**"]
include: ["*.md"]
exclude: []
devops:
url: https://host/devops-notes.git
mirror: tmp/mirrors/devops.git
paths: ["**"]
include: ["*.md"]
nanobot:
workspace:
paths: ["notes/**", "projects/**", "plans/**", "knowledge/**", "results/**", "cook/**"]
include: ["*.md"]
exclude: ["**/inbox/**"] # rozpracované zachyty před compile
develop:
paths: ["develop/**"]
include: ["*.md"]
exclude: ["develop/history.md"]
```
## Rozhodnutí
### D1 — Embeddings jako vrstva nad BM25, ne místo něj
Embeddingy přidávají parafrázi a cross-jazyk („prodloužení životnosti LEGO" najde „e-waste,
Mindstorms po ukončení podpory" — žádná slova se nepřekrývají) a chunk-level relevanci.
Nevyřeší exact match a čísla. Proto **hybrid**: FTS5 lexikálně, vektory sémanticky, RRF merge.
**Potvrzeno měřením**, ne argumentem — viz tabulka retrieval kvality výše: na těžkém dotazu
BM25 1/5 vs. embeddingy 3/5, na lexikálním naopak BM25 4/5.
### D2 — Model `qwen3-embedding:0.6b`
1024 dims, 32k ctx, 639 MB (q8_0), multilingual s češtinou jako first-class. Naměřeno:
cold 1,81 s, warm 0,043 s, 108 chunk/s v batchi 32. Na parafrázových dotazech 5/5 — **0,6B stačí**.
- **Tag psát vždy explicitně**: `latest` je 8b (4,7 GB), ne šestistovka.
- `keep_alive: -1` (číslo).
- **Volba modelu NENÍ silně vážící rozhodnutí.** Při 10³10⁴ chunků je plný re-embed jednotky
minut, takže přechod na `4b-q8_0` je odpoledne, ne rewrite. Signál pro upgrade: parafrázový
dotaz, kde správný chunk existuje a keyword dotaz ho najde, ale sémantická polovina ho
nevrátí ani v top-10.
- Storage není omezení: 10⁴ × 1024 × 4 B ≈ 40 MB. Matryoshka truncation netřeba.
### D3 — Vektory v `sqlite-vec` (`vec0`), ne BLOB + numpy
Rozhodující je rozpad nákladu, ne teorie: u BLOB+numpy je při 100k chunků 594 ms celkem, z toho
**read 382 ms + pack 190 ms a samotný dot jen 22 ms** — 96 % je Python režie na extrakci
a packování. `vec0` ji odřízne skenem v C: **19 ms @ 10k, 212 ms @ 100k**.
Pozor na zdůvodnění: **`vec0` není ANN index.** Dokumentovaná cesta dotazu je průchod
(lineární škálování 19 → 212 ms to potvrzuje). Výhoda je konstanta, ne asymptotika.
**Cena:** `sqlite-vec` je pre-v1 a README píše „expect breaking changes"; užší dotazovací plocha
(max 16 metadata sloupců, 4 partition keys, `IS NULL`/`LIKE` na metadatech nefunguje, auxiliary
sloupce nesmí do KNN `WHERE`); ztráta volné numpy matematiky (MMR re-ranking, truncation za běhu).
**Mitigace:** verze připíchnutá (`sqlite-vec==0.1.6`) a zapsaná v `meta.sqlite_vec_version`;
index je derived, `db/` gitignorovaná a rebuild jsou jednotky minut; `chunks` zůstává kanonická,
takže **návrat k BLOBu je přidání jednoho sloupce**. Blast radius breaking changu je „zůstaň na
staré verzi, nebo přizpůsob a přeindexuj", ne ztráta dat.
**Ověřeno smoke testem** (jinak by D3 padlo): `DELETE`/re-`INSERT`/`UPDATE` po `rowid`,
transakční `ROLLBACK`, `distance_metric=cosine`, `vec0` v témže souboru jako běžné tabulky,
join `vec_chunks.rowid = chunks.id`.
### D4 — Chunking strukturně vědomý
Viz sekce Chunking. Fixed-size sliding window zamítnuto.
### D5 — markdown-it-py, ne vlastní parser a ne frameworky
Dva různé případy:
- **Parser patří do knihovny.** Vlastní regex zná CommonMark edge cases jen do té míry, do jaké
si je ošetříš. markdown-it-py je malá, stabilní, zero-bloat (jen `mdurl`), dává proper AST.
Chunkovací **politika** ale zůstává vlastní.
- **Glue kód neobírat frameworkem.** Sync orchestrace, katalog, hybrid search — tady neexistuje
„malá dobrá knihovna", nabídka je binární: langchain/llama-index (stovky MB, abstrakce nad
sqlite3/subprocess/HTTP, měnící se API) nebo vlastních ~500 řádků. Tady „psát si sám" není
NIH, je to jediná racionální volba, protože alternativa obaluje čtyři stdlib volání.
Dependency set: `markdown-it-py`, `requests`, `numpy`, `pyyaml`, `sqlite-vec==0.1.6`.
### D6 — FTS5 tokenizer `unicode61 remove_diacritics 2` + prefix wildcardy
**Zavřeno měřením, ne odloženo na testování.** `remove_diacritics 1` a výš foldí diakritiku
(dotaz `zaloha` najde `záloha`; `0` ne). `unicode61` nestemuje, ale prefix wildcard to pokryje:
`záloh*` najde *záloha, zálohování, zálohy*. **Trigram tokenizer zamítnut** — netřeba.
Dotazová vrstva lepí `*` na termy delší než 2 znaky.
### D7 — Skill je samostatný, bez závislostí na jiné skilly
Vzory z `remind`/`note` se **kopírují, neimportují**. Vlastní `db.py`/`store.py`, vlastní
lockfile, **žádný `detach`**, nepřebírat `wiki_search.py` ani `note_capture._ascii_fold()`
(FTS5 `remove_diacritics` folding stejně řeší). Cena je duplikace kódu; hodnota je, že skill
nespadne s ničím jiným a dá se přenést.
### D8 — Mirror jako zdroj, sync offline
Git repa jsou kanonická data, index je derived. Mirror přes `git clone --mirror` / `fetch`.
**`fetch-on-query` zamítnut**: přidával `git fetch` *i embed nových chunků* do latence dotazu,
a při nedostupné Ollamě dělal z čerstvého dokumentu FTS-only výsledek. Místo toho offline cron
+ lock (viz Provozní model).
**Staleness není problém**: index je retrieval hint, ne source of truth — před odpovědí se
soubor vždy přečte čerstvý z disku. Dotaz může vidět index o minutu starší, což je přijatelné.
### D9 — `chunks` je jediná retrieval jednotka
`chunks_fts` i `vec_chunks` vracejí `chunks.id`, RRF slučuje je. Klíč `(source_id, path,
chunk_idx)`. Rank-merge dvou různých jednotek nemá definovaný význam → file-level BM25 vyloučen.
### D10 — Identita embedding prostoru v `meta`
`embedding_model`, `embedding_dims`, `normalized`, `query_prefix`, `chunker_version`.
Mismatch proti configu **odmítne dotaz** s „reindex needed". Bez toho by se smíchané vektory
ze dvou modelů projevily jako **tiché zhoršení výsledků, ne jako chyba** — nejdražší druh bugu.
### D11 — Indexace výhradně offline
Cron + lockfile, nikdy v tahu agenta. Důvod: `exec` timeout 60 s vs. plný index ~77 s.
### D12 — Rozsah indexace whitelistem
Viz sekce Rozsah indexace. `paths` je hradlo, `exclude` skalpel; workspace opt-in, git repa opt-out.
## Zamítnuté varianty
| Varianta | Proč ne |
|---|---|
| **Ollama Cloud embeddingy** | Cloud tier embedding endpoint **neservíruje** — 18 cloud modelů, žádný s capability `embedding`; `/api/embed` vrací `unauthorized`, zatímco completion na tomtéž modelu projde. Katalog na ollama.com to potvrzuje: všech 12 embedding modelů je jen ke stažení. Navíc by to porušilo lokalitu dat (celý korpus osobních poznámek do cizí služby) a plný re-index = ~10⁴ requestů na externí API |
| Generativní cloud model + pooling hidden states | Generativní modely nejsou kontrastivně trénované na retrieval; proto je capability oddělená |
| `nomic-embed-text` (137M) | EN-centric, pro češtinu nevýhodný |
| `bge-m3` (567M) | Umí dense+sparse hybrid z jednoho modelu, ale přes Ollama embeddings API jde dostat jen dense — výhoda mizí, FTS5 dělá tutéž roli levněji |
| `qwen3-embedding` 4b/8b jako start | Lepší multilingual skóre, ale 2,515 GB na kartě sdílené s chat modely. Upgrade je odpoledne (re-embed = minuty), tak začít malým |
| `*-q4_K_M` kvantizace | 4bit u embeddingu; šestistovka q4 v nabídce ani není |
| BLOB + numpy cosine | 96 % nákladu je Python režie; viz D3 |
| `.npy` + `mmap_mode='r'` (35 ms @ 100k) | Nejrychlejší, ale druhý soubor mimo DB, který se musí držet v sync s `chunks` — složitost bez přínosu, když `vec0` dává 19 ms na reálné škále |
| Externí vektorová DB (Chroma/Qdrant/pgvector) | Řeší problém, který na této škále neexistuje (server, jiná backup story, >10⁶ vektorů) |
| Trigram tokenizer | Netřeba, `remove_diacritics 2` + prefix wildcardy stačí (D6) |
| Entity/concept pages jako v llm-wiki | Uživatel poznámky strukturoval sám; LLM-kurátorovaná druhá vrstva by byla duplikace. Adresářová struktura repů JE primární tematický index — zpřístupňujeme ji (TOC), nereimplementujeme |
| `fetch-on-query` / `scan-on-query` | Viz D8 |
| Blacklist jako primární gate | Viz D12 |
| Embeddingy až když fuzzy dotazy selžou | Zamítnuto — jsou součástí od začátku |
## Konvence implementace
Vzory z existujících skillů (kopírovat, neimportovat — D7):
| Věc | Vzor | Zdroj |
|---|---|---|
| Rozdělení kódu | `db.py` (SCHEMA, `get_db()`, `_migrate()`, `init_db()`) + `store.py` (`connection()`/`transaction()` + veškeré SQL) + CLI bez inline SQL | `skills/remind/scripts/` |
| Migrace | idempotentní `CREATE ... IF NOT EXISTS` v jednom `SCHEMA` přes `executescript()` + `_migrate()` s `PRAGMA table_info` a `ALTER TABLE ADD COLUMN`. Žádná `schema_version` tabulka, žádný framework | `remind/scripts/db.py` |
| Connection | `sqlite3.connect(path, isolation_level=None)` + `PRAGMA journal_mode=WAL`, `foreign_keys=ON`, `row_factory=sqlite3.Row` | `remind/scripts/db.py` |
| Cesta k DB | `WORKSPACE = Path(__file__).resolve().parents[3]`, `WORKSPACE/"db"/"notes-index.sqlite"`, env override pro testy | `note/scripts/note_capture.py:25` |
| Shebang | entry point `#!/usr/bin/env -S uv run --script` + PEP 723; importovaný modul `#!/usr/bin/env python3` + PEP 723 | `remind/scripts/*` |
| Cron řádek | `* * * * * uv run .../scripts/notes_sync.py >> log/notes_sync_cron.log 2>&1` (`PATH` je v hlavičce crontabu) | server `crontab -l` |
| Lockfile | `notes/.compile.lock` je precedens → `db/.notes-sync.lock` | `note/scripts/note_compile.py` |
| SKILL.md | frontmatter jen `name` + `description` (folded `>`, EN, s `Triggers on:` a **funkční** negativní delimitací — nikdy jménem jiného skillu); tělo ~100 řádků; workspace-relativní `uv run skills/<name>/scripts/x.py` | `remind/SKILL.md` |
| Testy | `skills/<name>/tests/`, `uv run --with pytest pytest ...`, izolace přes `tmp_path` + monkeypatch modulového `DB_PATH` | `remind/tests/` |
| Deploy | `rsync -av --exclude '__pycache__' --exclude '.pytest_cache' skills/<name>/ nanobot@nanobot.hell:/home/nanobot/.nanobot/workspace/skills/<name>/` | `CLAUDE.md` |
Query interface: skill s CLI skriptem `notes_search.py` (vzor `remind_cli.py` — argparse,
subcommandy, `--help` místo plné flag reference v SKILL.md).
## Otevřené otázky
1. **Seznam rep** — která repa, jejich URL, kam mirrorovat, velikost/historie (vliv na fetch čas).
Musí doplnit autor; bez toho nelze naplnit `config/notes-search.yaml`.
2. **Benchmark modelu na uživatelských datech** — cross-jazyk test výše proběhl na workspace
obsahu (`cml/wiki`, `plans/`). Po přidání reálných rep ho zopakovat na nich.
## Verifikace
**Hotovo (2026-09-09)** — schéma i model ověřené smoke testem, viz tabulky faktů:
`vec0` mutace a transakčnost, KNN latence na dvou škálách, FTS5 external-content triggery
s češtinou, RRF merge nad `chunks.id`, cold load / throughput modelu, retrieval kvalita.
**Při implementaci:**
1. **Regresní test na osiřelé vektory** — smaž soubor → `vec_chunks` nesmí obsahovat jeho
rowidy. Tohle je jediná past, kterou schéma samo neochrání (kaskáda na `vec0` nedosáhne).
2. **Idempotence syncu** — dvakrát za sebou nad stejnou revizí: druhý běh nesmí nic změnit
(počty v `chunks`/`vec_chunks`/`chunks_fts` shodné, `indexed_rev` stejná).
3. **Lock** — spustit dva syncy současně; druhý musí skončit exit 0 bez zápisu.
4. **Degradovaný režim** — s vypnutou/nedosažitelnou Ollamou: sync uloží chunky
s `embedded_at IS NULL`, dotaz vrátí FTS-only výsledek a **řekne to**; po obnovení
Ollamy další sync vektory dosadí.
5. **`meta` guard** — podvrhni v configu jiný `model`/`dims` → dotaz musí skončit
„reindex needed", ne vrátit výsledky.
6. **Coverage report** — přidej md soubor do adresáře mimo `paths` → sync ho musí ohlásit v logu.
7. **Latence dotazu end-to-end** — cíl: pod 1 s při teplém modelu (embed dotazu 0,043 s
+ KNN ~19 ms + BM25 + RRF).