nanobot: 2026-09-10 12:33:37
This commit is contained in:
493
plans/final-wiki-hybrid-rag.md
Normal file
493
plans/final-wiki-hybrid-rag.md
Normal file
@@ -0,0 +1,493 @@
|
||||
# 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.hell` → `nvidia.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
|
||||
|
||||
```sql
|
||||
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 `path` — `DELETE 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, H1–H3 sekce), takže slepování přes hranice sekcí by
|
||||
embeddingy kazilo.
|
||||
|
||||
- Rozdělení po heading sekcích (H1–H3); 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 ~50–80 tokenů.
|
||||
Cílová granularita ~200–800 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í.
|
||||
|
||||
```yaml
|
||||
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,5–15 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).
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user