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

16 KiB
Raw Permalink Blame History

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

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