# 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, H1–H3 sekce), takže slepování přes hranice sekcí by embeddingy kazilo. - Rozdělení po heading sekcích (H1–H3); 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 ~50–80 tokenů. Cílová granularita ~200–800 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: \nQuery: `, 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 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,5–15 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//scripts/x.py` | `remind/SKILL.md` | | Testy | `skills//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// nanobot@nanobot.hell:/home/nanobot/.nanobot/workspace/skills//` | `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).