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

28 KiB
Raw Permalink Blame History

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

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 pathDELETE 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í.

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