From 6a1f9814c907c34312df502d92c6c7a2772ae1ea Mon Sep 17 00:00:00 2001 From: lachtan Date: Wed, 9 Sep 2026 14:58:13 +0200 Subject: [PATCH] plans --- plans/notes-search-hybrid-rag.md | 328 +++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 plans/notes-search-hybrid-rag.md diff --git a/plans/notes-search-hybrid-rag.md b/plans/notes-search-hybrid-rag.md new file mode 100644 index 0000000..5159365 --- /dev/null +++ b/plans/notes-search-hybrid-rag.md @@ -0,0 +1,328 @@ +# Notes Search — RO git repa → hybrid RAG index + +Stav: DRAFT — budeme ještě opracovávat, než se pustíme do realizace. +Vznik: diskuze 2026-09-XX (nahradit přesným datem při finalizaci). + +## Cíl + +Uživatel vede poznámky ve vlastních git repozitářích (adresářová struktura, md +soubory; témata: devops, traveling, mix všeho možného). Nanobot dostane +**read-only přístup** k mirrorům těchto repů a postaví nad nimi index, který +umožní rychlé hledání — tematické i syntaktické. Poznámky zůstávají +kanonickými daty v gitu; index je jen derived artifact, regenerovatelný. + +Repozitáře jsou read-only z pohledu agenta — nikdy nic nezapisujeme do +uživatelových repů, všechno derived žije ve workspace nanobota. + +## Vstupní fakta a omezení (ověřené v diskuzi) + +- **HW pro embedding inference**: RTX 4060/16 GB na serveru `nvidia.hell` + — **fyzicky jiný host** než běžiště nanobota. Embedding je network call + na Ollama HTTP API, ne lokální volání. Dosažitelnost endpointu zatím + neověřena (TODO před realizací). +- Uživatel sám navrhl dodat embeddings přes vlastní Ollama server — + embedding inference tedy není omezující faktor. +- Škála datasetu: pár repů, řádově stovky až tisíce md souborů, + po chunkování ~10⁴–10⁵ chunků. +- Obsah převážně česky + anglicky. +- Uživatel ukládá i embedding vektory — potřebujeme vektorové úložiště, + otázka je jen jaké (viz rozhodnutí D3). + +## Architektura + +``` +Zdroje: + git mirrory (RO clone/fetch, keyed na indexed-rev) + + nanobot workspace (živá data, scan path+size+mtime) + → ingest driver (git-diff / fs-scan — dva drivery, jeden indexer) + → chunker (md → chunky po heading struktuře) + → embeddings: Ollama API (qwen3-embedding:0.6b) — jen změněné chunky + → SQLite: catalog + FTS5 tabulka + embedding BLOB sloupce + → query: hybrid search (BM25 via FTS5 + cosine), top-k merge (RRF) +``` + +Konfigurace zdrojů: `config/notes-search.yaml` (viz sekce Konfigurace). + +Jeden SQLite soubor (`db/notes-index.sqlite` dle konvence AGENTS.md: +databáze do `db/*.sqlite`), který nese: +- **catalog**: `repo, path, title, tags, headings, sha, indexed_at` +- **FTS5 tabulka**: fulltext s BM25 rankingem (exact/lexikální půlka) +- **embedding tabulka**: `path, chunk_idx, text, embedding BLOB` + (sémantická půlka; BLOB + numpy cosine brute-force) + +## 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 potřeba, vždy + aktuální. Základ, ne fallback. +2. **Tematicky — vygenerovaný TOC** (`toc.md` per repo: kategorie → + soubor → jedna řádka; title + tagy + headings). Index-first navigace + jako v llm-wiki, ale generovaná, ne LLM-kurátorovaná. +3. **Fuzzy/sémanticky — hybrid search**: FTS5 (BM25) + embeddings + (cosine), merge přes RRF. Používá se, když TOC browsing nezabere. + +## Incrementální update (nové dokumenty ve zdrojích) + +1. `git fetch` mirroru; `git diff --name-status ..HEAD` + → seznam přidaných/změněných/smazaných/přejmenovaných md souborů. +2. Chunker zpracuje jen změněné soubory; staré chunky daného `path` + se smažou (`DELETE WHERE path = ?`), nové se vloží. +3. Embedding: jen změněné chunky → Ollama API → upsert do SQLite. + Nový soubor = pár desítek chunků, sekundy. +4. FTS5 sync ve stejné transakci. +5. Commit `indexed-rev = HEAD`; smazaný soubor = delete chunků + FTS. + +Vlastnosti: **idempotentní** (re-run nad stejnou revizí = no-op), +degradovaný režim (Ollama nedostupná → FTS5 funguje dál, chunky +bez vektoru poznáme přes `embedding IS NULL`, dosadí se při dalším +syncu). Full re-index jen při změně embedding modelu. + +## Rozhodnutí a důvody (včetně diskuze) + +### D1: Embeddings ano — jako vrstva nad BM25, ne místo něj + +Diskuze: nejprve navržen „zero embedding" stack (grep + TOC + FTS5). +Uživatel reagoval, že embeddings může dodat přes vlastní Ollama server, +a upřesnil, že embedding vektory se někam musí ukládat. + +Embeddings přidávají nad BM25 dvě věci: parafráze a cross-jazyk +(„prodloužení životnosti LEGO" najde „e-waste, Mindstorms po ukončení +podpory" — žádná slova se nepřekrývají, BM25 selže) a chunk-level +relevance (najde konkrétní odstavec v dlouhém souboru, ne jen soubor). +Nevyřeší ale exact match a čísla — proto **hybrid**: FTS5 pro +lexikální, embeddings pro sémantickou stranu, top-k merge (RRF). +Embeddings jsou doplněk, ne náhrada. + +### D2: Embedding model — Qwen3-Embedding-0.6B + +Kritéria (od uživatele): běží na 4060/16 v jeho lokálním serveru, +rozumná velikost uložení vektorů. + +- **VRAM ~1.2 GB** — pohodové vedle dalších modelů na Ollamě. +- **32k context** — dlouhé dokumenty bez omezování chunkování. +- **Multilingual, top MTEB ve své třídě** — čeština first-class, + klíčové pro české poznámky. +- **1024 dims** — ~10⁵ chunků × 1024 × 4 B ≈ 400 MB v SQLite. Pohodové. + Podporuje Matryoshka truncation (512/256/128 dims) kdyby storage + vadil — u této škály netřeba. + +Alternativy zvažované a zamítnuté: +- **nomic-embed-text** (137M params, nejpoužívanější na Ollamě) — EN-centric, + pro češtinu nevýhodný. +- **bge-m3** (567M) — multilingual a umí z jednoho modelu dense+sparse + hybrid; ale přes Ollama embeddings API jde dostat jen dense výstup, + takže výhoda sparse v našem stacku mizí (FTS5 dělá tutéž roli levněji). + +Poznámka k jistotě: čísla a pozice z web-searchů (MTEB srovnání), +ne osobní benchmark. Před realizací ověřit na reálných datech +(dotaz „e-waste" vs „prodloužení životnosti" atp.). + +**Volba modelu je vážící rozhodnutí** — změna = re-embed celého +datasetu. Při ~10⁵ chunků na 4060 je full re-index otázkou minut, +přijatelné, ale ne měnit z rozmaru. + +### D3: Vektorové úložiště — embeddings jako BLOB v SQLite + +Diskuze: já jsem zpočátku tvrdil „vektorovou databázi nepotřebuješ"; +uživatel oprávněně namítl, že **sqlite-vec je svým způsobem taky +vektorová databáze, i když embedded** — a že nikdy neříkal, že buduje +externí službu, jen že vektory někam ukládat potřebuje. Souhlas: +šlo o spor o pojem, ne o věc. + +Na škále ~10⁴–10⁵ vektorů je brute-force cosine v numpy pod 100 ms; +ANNS indexy (HNSW apod.) dávají smysl nad ~10⁶ vektorů. Konkrétní volby: + +| | Embeddings v SQLite (BLOB) | sqlite-vec (`vec0`) | Chroma/Qdrant/pgvector | +|---|---|---|---| +| Tvar | BLOB sloupec, cosine v Pythonu | virtuální tabulka, KNN v SQL | samostatný service/DB | +| Vyhledávání | načti vše, spočítej top-k | `MATCH` operátor, indexovaný | KNN server-side | +| Infra | nic | load extension | server, jiná backup story | +| Kdy se vyplatí | do ~10⁵ vektorů | ~10⁵–10⁶, metadata filter + KNN v jednom dotazu | >10⁶ vektorů | + +Volba: **BLOB v SQLite nyní**, schéma navrženo tak, aby migrace na +sqlite-vec byla mechanická (import jednoho sloupce do `vec0` tabulky, +žádná regenerace). Externí vektorové DB zamítnuty — řeší problém, +který na této škále neexistuje (YAGNI). + +### D4: Chunking — strukturně vědomý, po heading hierarchii + +- Rozdělení po heading sekcích (H1–H3), každý chunk nese breadcrumb + (`soubor > sekce > podsekce`) v metadatech i v embedding textu — + vektor tak nesou 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ů per chunk; menší chunk = + přesnější hit na místo v dokumentu. Qwen3-Embedding má 32k ctx, + takže se krčit nemusíme. +- Frontmatter tagy zůstávají v katalogu (na filtrování), do embedding + textu jde jen title. +- Kód bloky a tabulky se nerozbíjejí (drží pohromadě); checklistové + soubory chunkujeme po bullet blocích. +- Token counting: aproximace ~4 znaky/token (přesný tokenizer pro + chunk sizing netřeba, cílové okno je široké). + +Proč strukturní: uživatelovy poznámky jsou md se smysluplnou strukturou +(adresáře = témata, soubory, H1–H3 sekce); slepování přes hranice sekcí +by embeddings kazilo. Fixed-size sliding window zamítnuta. + +### D5: Knihovny — markdown-it-py, ne vlastní parser a ne frameworky + +Diskuze: původní návrh byl „100 řádků vlastního regex chunkeru, +stdlib only". Uživatel zpochybnil, dává smysl psát si všechno sám. +Výsledek revize — rozlišit dva případy: + +- **Parser do knihovny**: vlastní regex zná CommonMark edge cases + (setext headings `===`, nested listy, HTML bloky) 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** (split body + vs merge prahy, breadcrumb) ale zůstává vlastní (~50 řádků nad + tokeny). Původní „vlastní regex" byla varianta šetřící na špatném + místě — parsery patří do knihoven. +- **Glue kód neobírat frameworkem**: sync orchestrace, katalog, + hybrid search — tady neexistuje „malá dobrá knihovna", nabídka je + binární: langchain / llama-index (frameworky, stovky MB, abstrakce + nad sqlite3/subprocess/HTTP, API se mění pod rukama) nebo vlastní + ~500 řádků. „Psát si sám" tady není NIH syndrom, je to jediná + racionální volba, protože alternativou je framework obalující + 4 stdlib/utility volání. + +Výsledný externí dependency set: **markdown-it-py, requests, numpy**. +Vše malé, stabilní, žádný framework. Konkrétní volby per část: + +| Část | Řešení | Proč | +|---|---|---| +| MD parsing + chunking | markdown-it-py + vlastní split politika | parser hotový/testovaný, politika naše | +| Git sync | subprocess + git CLI | nic knihovního netřeba | +| Katalog/FTS5/BLOB | sqlite3 (stdlib) | nic k přidání | +| Ollama client | requests | pár řádků, knihovna nic nepřidá | +| Hybrid merge (RRF) | numpy | ~20 řádků | +| Config (notes-search.yaml) | pyyaml | ruční editace + komentáře; TOML zamítnut uživatelem | + +### D6: FTS5 tokenizer — unicode61, s vědomím české mezer + +`unicode61` dělá diakritiku-case-folding („ZALOŽIT" najde „založit"), +ale nemá stemming — „záloha" nenajde „zálohování". Nástřel: doufat, +že BM25 + embeddings hybrid mezeru překryjí (u hybridu obvykle ano); +záložní volba trigram tokenizer (SQLite 3.34+) pro substring matching, +kdyby se ukázalo, že hybrid nestačí. Rozhodnutí odloženo na testování +na reálných datech. + +### D7: Oddělené patterny — notes search ≠ llm-wiki + +llm-wiki (`cml/`) je kurátorovaný knowledge store pro cílené +ingestování cizích zdrojů (LLM píše entity/concept pages). Notes +search je **search index nad existujícími uživatelskými poznámkami** +— uživatel je strukturoval sám, LLM-kurátorovaná druhá vrstva by +byla duplikace. Dva patterny, nemíchat; adresářová struktura repů je +primární tematický index, který jen zpřístupňujeme (TOC), nereimplementujeme. + +Kontext: současný obsah llm-wiki (7 zdrojů o coding agentech + 1 o +LEGO Mindstorms) uživatel hodnotí jako „nic moc, bude to chtít pojmout +úplně jinak" — tohle řešení je odpovědí na ten směr. + +### D8: Mirror jako zdroj, fetch-on-query vs cron sync + +Git repa jsou kanonická data; index je derived. Mirror přes +`git clone --mirror` / fetch. Staleness mirroru je jediné reálné +riziko — mitigace: fetch-on-query (fetch vteřiny, voláno před +dotazem) s idempotentním index update; cron/heartbeat sync volitelně +navíc. Incremental reindex jen změněných souborů (typicky pár +souborů per sync). + +## Ingest zdrojů — dva drivery, jeden indexer + +Index přijímá dva typy zdrojů, podle toho se liší jen detekce změn; +chunker, embedding, ukládání i query vrstva jsou společné. + +### Driver: git (RO mirrory) + +- Detekce změn: `git fetch` + `git diff --name-status ..HEAD` + (R-status = rename → přesun záznamů, M = reindex, D = delete). +- Uložená `indexed-rev` per source v katalogu je vstupní bod. +- Fetch-on-query (fetch vteřiny) před dotazem; idempotentní update. + +### Driver: nanobot workspace (živá data) + +- Detekce změn: walk stromu + porovnání `(path, size, mtime)` proti + katalogu; content hash (SHA-256) jako autorita, mtime jako rychlý + pre-filter. Žádný git, žádný rev — soubory se mění pod rukama + (Dream přepisuje MEMORY.md, skilly appendují logy). +- Scan-on-query místo fetch-on-query (walk workspace je levný). +- Nekonzistence v čase nevadí: index je retrieval hint, ne source of + truth — před odpovědí se soubor vždy přečte čerstvý z disku. +- Embeddings tabulka je de-facto komprimovaná kopie obsahu (i keep.md, + osobní věci) v jednom SQLite souboru — vše lokální (vlastní Ollama, + žádná třetí služba), ale uvědomit si to v backup story. +- Vyloučené z indexace (rozumný start, doladíme měřením): `MEMORY.md`, + `SOUL.md`, `USER.md` (krátké, vždy v kontextu — jen noise), `tmp/`, + `src/` (git klony), binárky, `history.jsonl` (obří, low signal). + +## Konfigurace zdrojů (YAML) + +Formát rozhodnut v diskuzi: **YAML** (ne TOML — uživatel výslovně +zamítl; zapsáno do USER.md). Důvod volby YAML nad JSON: config se +edituje ručně (uživatel při přidání repa), komentáře v configu mají +hodnotu; JSON komentáře nemá. `pyyaml` přidán do dependency set. + +Jeden soubor `config/notes-search.yaml`, dvě sekce podle typu zdroje. +**Klíč sekce = source id** (stabilní, visí na něm katalog i embeddingy; +URL/path se můžou změnit, klíč ne; rename = explicitní invalidace indexu +daného zdroje — chceme pod kontrolou, ne implicitní). + +```yaml +# Git zdroje — RO mirrory. Klíč = source id. +sources: + git: + travel: # source id (stabilní) + url: git@host:travel-notes.git + mirror: tmp/mirrors/travel.git # kam clone --mirror + devops: + url: https://host/devops-notes.git + mirror: tmp/mirrors/devops.git + + # Nanobot workspace zdroje — glob vzory + nanobot: + notes: + paths: ["notes/**"] + include: ["*.md"] # default *.md + exclude: [] # volitelné + projects: + paths: + - "projects/**" + - "cook/**" + - "knowledge/**" + - "plans/**" + - "results/**" + include: ["*.md"] +``` + +Pravidla: +- `paths` = glob vzory (vyjmenovat lze celý adresář i podstrom). +- `include` default `*.md`; jiné přípony explicitně až kdyby. +- `exclude` deny-list přidáme až když se šum objeví (YAGNI). +- Source id nesmí kolidovat mezi `git` a `nanobot` sekcemi — + katalog je sdílený (`source` sloupec). + +## Otevřené otázky (před realizací) + +1. **Adresa/endpoint Ollamy na nvidia.hell** — dohledat v projects/ + keep.md nebo od uživatele; ověřit reachability z runtime nanobota. +2. **Benchmark embedding modelu na reálných datech** — ověřit MTEB + pozice na pár českých dotazech (parafráze cross-jazyk test). +3. **Seznam rep** — která repa, kde mirrorovat, velikost/historie + (vliv na fetch čas). +4. **Trigger syncu** — fetch/scan-on-query, heartbeat, nebo obojí. +5. **Query interface** — jak se nanobota ptát: ad-hoc dotazy v chatu, + skill (`search my notes`), nebo obojí. +6. **Vyloučené workspace cesty** — potvrdit výchozí blacklist + (MEMORY.md/SOUL.md/USER.md, tmp/, src/, history.jsonl) měřením. +7. **Presné datum vzniku plánu** — doplnit. + +## Nezávislá rozhodnutí (odloženo, YAGNI) + +- Embeddings až když fuzzy dotazy selžou — **zamítnuto v diskuzi**, + embeddings jsou součástí od začátku (uživatel je dodává přes Ollamu). +- Externí vektorová DB (Chroma/Qdrant/pgvector) — zamítnuta (D3). +- Entity/concept pages jako v llm-wiki — zamítnuty (D7). +- Full re-index strategie mimo změnu modelu — netřeba (idempotentní + incremental, D-sync).