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

331 lines
16 KiB
Markdown
Raw 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.
# Notes Search — RO git repa → hybrid RAG index
> **Superseded by `final-wiki-hybrid-rag.md`** (2026-09-09) — tento draft je historie.
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 <indexed-rev>..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 (H1H3), 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 ~5080
tokenů. Cílová granularita ~200800 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, H1H3 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 <indexed-rev>..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).