This commit is contained in:
lachtan
2026-09-09 14:58:13 +02:00
parent 2c69ad6761
commit 6a1f9814c9

View File

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