From 4986ef05753de8325041356b7c5347c5d79925f6 Mon Sep 17 00:00:00 2001 From: Elton Turing Date: Fri, 4 Sep 2026 18:04:35 +0200 Subject: [PATCH] Baue den Index als SQLite mit FTS5 und sqlite-vec, dazu die Suche MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entscheidung Andreas 2026-09-04 (spine/core#8): kein Datenbankdienst. Der Index ist eine abgeleitete Sicht auf die Markdown-Ablage in einer Datei im Volume, jederzeit neu baubar. image/km/index.py zerlegt die Dateien in Chunks (Chunking aus info/ai/scripts/index_docs.py übernommen), hält Volltext in FTS5 und Vektoren aus bge-m3 in sqlite-vec, inkrementell über SHA-256 je Datei. image/km/search.py sucht Volltext führend, Vektor ergänzend, mit Filtern vor dem Ranking (0 Treffer statt gesperrt). Ingest: Kommentare bei der Erstbefüllung repo-weit statt je Vorgang — die erste Fassung hing bei live/live nach 371 Vorgängen; jetzt 2175 in zwei Minuten. Gemessen: Volltext über 61.000 Chunks in 3 s, Vektoren 0,31 s je Chunk auf der CPU-Box. Kein Bytecode mehr im Repo. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 3 + docs/index-suche.md | 89 +++++ docs/ingest-gitea.md | 18 +- image/README.md | 7 +- .../__pycache__/ingest_gitea.cpython-314.pyc | Bin 22345 -> 0 bytes image/km/index.py | 324 ++++++++++++++++++ image/km/ingest_gitea.py | 34 +- image/km/search.py | 137 ++++++++ image/requirements.txt | 1 + 9 files changed, 599 insertions(+), 14 deletions(-) create mode 100644 .gitignore create mode 100644 docs/index-suche.md delete mode 100644 image/km/__pycache__/ingest_gitea.cpython-314.pyc create mode 100755 image/km/index.py create mode 100755 image/km/search.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00f2d38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.venv/ diff --git a/docs/index-suche.md b/docs/index-suche.md new file mode 100644 index 0000000..af1e362 --- /dev/null +++ b/docs/index-suche.md @@ -0,0 +1,89 @@ +# Index und Suche — SQLite mit FTS5 und sqlite-vec + +Entscheidung Andreas, 2026-09-04 (spine/core#8): kein Datenbankdienst. Der +Index ist eine **abgeleitete Sicht** auf die Markdown-Dateien der Ablage und +liegt als eine SQLite-Datei neben ihr. Er darf jederzeit gelöscht und neu +gebaut werden — die Wahrheit sind die Dateien. + +Code: `image/km/index.py` (bauen), `image/km/search.py` (suchen). +Abhängigkeiten: `requests`, `sqlite-vec` (`image/requirements.txt`). + +## Was drin ist + +| Tabelle | Inhalt | +|---|---| +| `docs` | eine Zeile je Datei: Pfad, Namespace, Projekt, Klassifikation, Typ (`vorgang`, `doku`, …), Zustand, Titel, Zeitstempel, SHA-256, ob eingebettet | +| `chunks` | Absatz-Chunks je Datei (max. 600 Zeichen, 60 Überlappung — dasselbe Chunking wie der Wissens-MCP); der Titel ist Chunk 0 | +| `chunks_fts` | FTS5 über die Chunks, Tokenizer `unicode61` **ohne** Diakritika-Entfernung (ä bleibt ä) | +| `chunks_vec` | sqlite-vec, `bge-m3` mit 1024 Dimensionen, rowid = Chunk-ID | +| `verweise` | Kanten aus dem Frontmatter (`verweise:`), beide Richtungen abfragbar | + +Die Filterspalten sind der Punkt, an dem die Zugriffsregel greift: Namespace, +Projekt, Klassifikation, Typ, Zustand sind Spalten in `docs`, und die Suche +filtert **vor** dem Ranking. Wer eine Klassifikation nicht sehen darf, bekommt +`0 Treffer` — nicht „vorhanden, aber gesperrt" (spine/core#18). + +## Bauen + +```bash +# Volltext (Sekunden) — nach jedem Ingest +image/km/index.py --ablage /srv/km/ablage + +# dazu Vektoren fuer neue und geaenderte Dateien +image/km/index.py --ablage /srv/km/ablage --embed + +# nur ausstehende Vektoren nachziehen, z. B. nachts +image/km/index.py --ablage /srv/km/ablage --embed-only [--embed-limit 200] +``` + +Inkrementell über den SHA-256 je Datei: unveränderte Dateien werden nicht +angefasst, geänderte komplett neu zerlegt (alte Chunks und Vektoren raus, +neue rein), verschwundene entfernt. Der Volltext ist sofort da; die Vektoren +folgen, sortiert nach `aktualisiert` absteigend — das Neueste zuerst. + +## Suchen + +```bash +search.py "worktree prune" # hybrid: Volltext + Vektor, RRF +search.py --mode fts "live/live#2181" # exakt +search.py --mode vec "warum braucht der bus keine persistenz mehr" +search.py --ns 42i --typ vorgang --zustand open "i18n" +search.py --classification lan --classification public "…" # Sichtkreis +search.py --json "…" +``` + +Volltext führend, Vektor ergänzend (spine/core#1). Hybrid fusioniert beide +Ranglisten per Reciprocal Rank Fusion; ausgegeben wird ein Treffer je Datei +mit dem besten Chunk als Anriss. Die FTS-Anfrage macht aus jedem Wort einen +Präfix (`worktree*`), Zeichenketten mit Sonderzeichen (`live/live#2181`) +werden wörtlich gesucht. + +## Gemessen am 2026-09-04 (Mac, Ablage mit 3424 Vorgängen aus elf Repos) + +| Schritt | Menge | Dauer | +|---|---|---| +| Volltext-Index, Erstbau | 2935 Dateien, 61.141 Chunks | 3,1 s | +| Volltext, inkrementell | 491 neue Dateien | 0,3 s | +| Vektoren | 1756 Chunks (30 Dateien) | 550 s → **0,31 s je Chunk** | +| Vektoren, hochgerechnet | 64.392 Chunks | **~5,5 h** | +| FTS-Suche | — | unter 50 ms | +| Vektorsuche | Embedding der Anfrage + Nachbarn | ~1 s, davon fast alles das Embedding | + +Die Vektorzeit ist der Rechner, nicht die Leitung: `/api/embed` mit 32 Texten +im Batch liefert 0,31 s je Text, einzeln 1,0 s — die Ollama-Instanz auf dem +s18-CT rechnet auf der CPU (`OLLAMA_VULKAN=0`, siehe `info: lan/infra/wissens-mcp.md`). +Die Erstbefüllung fällt einmal an; danach kostet ein geändertes Ticket +Sekunden. Wer es schneller braucht, hängt ein GPU-Embedding ans Gateway — +das ist eine Betriebsfrage, keine am Index. + +**Dateigröße:** 62 MB für den Volltext über 61.000 Chunks; mit Vektoren +(1024 × 4 Byte je Chunk) kommen rund 260 MB dazu. + +## Was fehlt + +- **Deutsche Wortformen.** `unicode61` stemmt nicht; „Sperrung" findet + „gesperrt" nur über die Vektorsuche. Falls das nicht reicht: Trigramm- + Tokenizer, keine neue Datenbank. +- **Kanten zu Doku-Seiten.** `verweise` kennt heute nur Vorgang→Vorgang. +- **Der Dienst.** Heute zwei Skripte; `read`/`search`/`summary` als HTTP-API + und MCP-Endpunkt kommen darüber (Reihenfolge in `image/README.md`). diff --git a/docs/ingest-gitea.md b/docs/ingest-gitea.md index 81e2015..6cd9699 100644 --- a/docs/ingest-gitea.md +++ b/docs/ingest-gitea.md @@ -67,11 +67,15 @@ KM_GITEA_URL=https://git.home KM_NAMESPACE=familie KM_CLASSIFICATION=home \ |---|---|---|---| | spine/core | 20 | 6 s | Erstbefüllung | | spine/core | 0 gesehen | 0,3 s | inkrementell, nichts geändert | -| 42i/intern | 1165 | ~3 min | Erstbefüllung, ein API-Aufruf je Vorgang für die Kommentare | +| 42i/intern | 1165 | ~3 min | Erstbefüllung, noch ein API-Aufruf je Vorgang für die Kommentare | +| live/live | 2175 | ~2 min | Erstbefüllung mit repo-weitem Kommentar-Endpunkt (ein Aufruf je 50 Kommentare) | +| elf Repos (live, intern, spine/*, hive/*) | 2241 | 94 s | ein Lauf | -Die Erstbefüllung kostet einen Aufruf je Vorgang mit Kommentaren; das ist -die Last auf der Forge, von der #13 spricht. Sie fällt einmal an. Danach -kostet ein Lauf einen Aufruf je Repo plus einen je geändertem Vorgang. +Die erste Fassung holte die Kommentare je Vorgang und blieb bei live/live +nach 371 Vorgängen an einer offenen Verbindung hängen. Seitdem gilt: bei der +Erstbefüllung kommen die Kommentare **repo-weit** (`/issues/comments`, älteste +zuerst), inkrementell je geändertem Vorgang. Dazu ein Verbindungs-Timeout und +Fortschritt je 100 Vorgänge auf stderr. ## Was noch fehlt (Reihenfolge) @@ -79,10 +83,8 @@ kostet ein Lauf einen Aufruf je Repo plus einen je geändertem Vorgang. `wake.km` geweckt werden (spine/core#12: Gitea-Webhook → HTTP→Bus-Adapter → Bus → km) und dann den inkrementellen Lauf machen. Der periodische Abgleich bleibt daneben Pflicht. -2. **Suche über die Ablage.** Volltext führend, Vektor ergänzend (spine/core#1). - Der bestehende Indexer `info/ai/scripts/index_docs.py` chunkt Markdown mit - Frontmatter — die Vorgangsdateien sind dafür gebaut. Welche Datenbank(en) - dahinter stehen, ist noch nicht entschieden (Stand 04.09.). +2. ~~Suche über die Ablage.~~ Gebaut: SQLite mit FTS5 und sqlite-vec, siehe + [index-suche.md](index-suche.md). 3. **Verknüpfung mit Doku und Memory.** Die `verweise:` sind erst Kanten zwischen Vorgängen. Kanten zu Doku-Seiten (ein Ticket, das eine Seite betrifft) und die Konsistenzprüfung gegen abgelegte Festlegungen kommen diff --git a/image/README.md b/image/README.md index de94789..afa9dac 100644 --- a/image/README.md +++ b/image/README.md @@ -3,9 +3,10 @@ Hier entsteht `git.42i.org/spine/km`: die API, die Ablage, der MCP-Endpunkt und die prüfende Schreibfunktion. -**Erster Baustein vorhanden:** `km/ingest_gitea.py` holt Tickets und Kommentare -aus Gitea in die Ablage (spine/core#13, Doku in -[../docs/ingest-gitea.md](../docs/ingest-gitea.md)). Der Rest steht in +**Vorhanden:** `km/ingest_gitea.py` holt Tickets und Kommentare aus Gitea in +die Ablage ([../docs/ingest-gitea.md](../docs/ingest-gitea.md)); `km/index.py` +und `km/search.py` bauen den SQLite-Index (FTS5 + sqlite-vec) und suchen +darüber ([../docs/index-suche.md](../docs/index-suche.md)). Der Rest steht in [../docs/konzept.md](../docs/konzept.md). Die Reihenfolge, in der es sinnvoll wächst: diff --git a/image/km/__pycache__/ingest_gitea.cpython-314.pyc b/image/km/__pycache__/ingest_gitea.cpython-314.pyc deleted file mode 100644 index 978df1a40d68c8a018bec15ffb67851723ec6bc9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22345 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 diff --git a/image/km/index.py b/image/km/index.py new file mode 100755 index 0000000..06c8224 --- /dev/null +++ b/image/km/index.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +"""Index ueber die km-Ablage: SQLite mit FTS5 (Volltext) und sqlite-vec (Vektor). + +Der Index ist eine abgeleitete Sicht auf die Markdown-Dateien der Ablage und +darf jederzeit geloescht und neu gebaut werden -- die Wahrheit liegt in den +Dateien (spine/core#8, Entscheidung 2026-09-04). Eine Datei im Volume, kein +Dienst. + +Inkrementell ueber den SHA-256 je Datei: unveraenderte Dateien werden nicht +angefasst, geaenderte komplett neu zerlegt und eingebettet, verschwundene +entfernt. Embeddings kommen aus Ollama (`bge-m3`, 1024 Dimensionen) wie beim +Wissens-MCP; ohne `--embed` wird nur der Volltext gebaut, was in Sekunden +geht -- die Vektoren lassen sich spaeter nachziehen (`--embed --embed-only`). + +Chunking (Absaetze, max_chars, Ueberlappung) ist aus +`info/ai/scripts/index_docs.py` uebernommen, nicht neu erfunden. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import re +import sqlite3 +import sys +import time +from pathlib import Path + +import requests +import sqlite_vec + +EMBED_DIM = 1024 +SCHEMA = """ +CREATE TABLE IF NOT EXISTS docs ( + path TEXT PRIMARY KEY, + namespace TEXT NOT NULL, + projekt TEXT, + classification TEXT NOT NULL, + typ TEXT NOT NULL, + zustand TEXT, + titel TEXT NOT NULL, + aktualisiert TEXT, + sha256 TEXT NOT NULL, + indexiert TEXT NOT NULL, + eingebettet INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS chunks ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL REFERENCES docs(path) ON DELETE CASCADE, + idx INTEGER NOT NULL, + text TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS chunks_path ON chunks(path); +CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5( + text, titel UNINDEXED, path UNINDEXED, + content='chunks', content_rowid='id', tokenize='unicode61 remove_diacritics 0' +); +CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN + INSERT INTO chunks_fts(rowid, text) VALUES (new.id, new.text); +END; +CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN + INSERT INTO chunks_fts(chunks_fts, rowid, text) VALUES ('delete', old.id, old.text); +END; +CREATE TABLE IF NOT EXISTS verweise ( + von TEXT NOT NULL, nach TEXT NOT NULL, PRIMARY KEY (von, nach) +); +CREATE INDEX IF NOT EXISTS verweise_nach ON verweise(nach); +""" +SCHEMA_VEC = f"CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(embedding float[{EMBED_DIM}]);" + + +def connect(db: Path) -> sqlite3.Connection: + con = sqlite3.connect(db) + con.enable_load_extension(True) + sqlite_vec.load(con) + con.enable_load_extension(False) + con.execute("PRAGMA journal_mode=WAL") + con.execute("PRAGMA foreign_keys=ON") + con.executescript(SCHEMA) + con.execute(SCHEMA_VEC) + return con + + +# ---------------------------------------------------------------- Dateien + +def parse_frontmatter(text: str) -> tuple[dict[str, str], str]: + if not text.startswith("---"): + return {}, text + lines = text.splitlines() + meta: dict[str, str] = {} + for end in range(1, len(lines)): + if lines[end].strip() == "---": + for line in lines[1:end]: + if ":" not in line or line.startswith((" ", "-", "#")): + continue + k, v = line.split(":", 1) + meta[k.strip()] = v.strip().strip("\"'") + return meta, "\n".join(lines[end + 1:]).lstrip("\n") + return {}, text + + +def split_list(value: str) -> list[str]: + value = (value or "").strip().strip("[]") + return [p.strip().strip("\"'") for p in re.split(r"[,;]", value) if p.strip()] + + +def derive_title(body: str, fallback: str) -> str: + m = re.search(r"^#\s+(.+)$", body, re.MULTILINE) + return m.group(1).strip() if m else fallback + + +def doc_fields(rel: str, meta: dict[str, str], body: str, default_ns: str) -> dict: + parts = rel.split("/") + namespace = meta.get("namespace") or (parts[0] if len(parts) > 1 else default_ns) + typ = meta.get("typ") or meta.get("type") or ("vorgang" if meta.get("quelle") == "gitea" else "doku") + projekt = meta.get("projekt") or meta.get("project") + if not projekt and typ == "vorgang" and meta.get("repo"): + projekt = meta["repo"].split("/")[0] + return { + "namespace": namespace, + "projekt": projekt or None, + "classification": meta.get("classification") or "lan", + "typ": typ, + "zustand": meta.get("zustand") or meta.get("state"), + "titel": meta.get("titel") or meta.get("title") or derive_title(body, rel), + "aktualisiert": meta.get("aktualisiert") or meta.get("updated"), + "verweise": split_list(meta.get("verweise", "")), + } + + +# ---------------------------------------------------------------- Chunking (aus index_docs.py) + +def split_into_paragraphs(text: str) -> list[str]: + return [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()] + + +def split_long_paragraphs(paragraphs: list[str], max_chars: int, overlap: int) -> list[str]: + out: list[str] = [] + step = max(1, max_chars - max(0, overlap)) + for p in paragraphs: + if len(p) <= max_chars: + out.append(p) + continue + start = 0 + while start < len(p): + end = min(len(p), start + max_chars) + out.append(p[start:end]) + if end == len(p): + break + start += step + return out + + +def chunk_paragraphs(paragraphs: list[str], max_chars: int, overlap: int) -> list[str]: + chunks: list[str] = [] + cur: list[str] = [] + cur_len = 0 + for p in paragraphs: + if cur and cur_len + len(p) + 2 > max_chars: + chunks.append("\n\n".join(cur)) + if overlap > 0: + keep: list[str] = [] + keep_len = 0 + for q in reversed(cur): + keep.insert(0, q) + keep_len += len(q) + 2 + if keep_len >= overlap: + break + cur, cur_len = keep, sum(len(q) + 2 for q in keep) + else: + cur, cur_len = [], 0 + cur.append(p) + cur_len += len(p) + 2 + if cur: + chunks.append("\n\n".join(cur)) + return chunks + + +def chunk_body(body: str, max_chars: int, overlap: int) -> list[str]: + paras = split_long_paragraphs(split_into_paragraphs(body), max_chars, overlap) + return chunk_paragraphs(paras, max_chars, overlap) + + +# ---------------------------------------------------------------- Embeddings + +class Embedder: + def __init__(self, url: str, model: str) -> None: + self.url = url.rstrip("/") + self.model = model + self.session = requests.Session() + + def embed(self, text: str) -> list[float]: + return self.embed_many([text])[0] + + def embed_many(self, texts: list[str]) -> list[list[float]]: + """Batch ueber /api/embed. Gemessen am 04.09. gegen bge-m3 (CPU): + 0,31 s je Text bei 32 im Batch, 1,0 s einzeln -- der Rechner ist + gebunden, nicht die Leitung; Batch spart Roundtrips, nicht Rechenzeit.""" + last: Exception | None = None + for attempt in range(3): + try: + r = self.session.post(f"{self.url}/api/embed", + json={"model": self.model, "input": [t[:600] for t in texts], + "keep_alive": "30m"}, + timeout=(10, 600)) + r.raise_for_status() + vs = r.json().get("embeddings") or [] + if len(vs) != len(texts) or any(len(v) != EMBED_DIM for v in vs): + raise RuntimeError(f"embeddings fehlen oder falsche Dimension ({len(vs)}/{len(texts)})") + return vs + except Exception as e: # noqa: BLE001 + last = e + time.sleep(2 ** attempt) + raise RuntimeError(f"embedding fehlgeschlagen: {last}") + + +# ---------------------------------------------------------------- Lauf + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def index_file(con: sqlite3.Connection, ablage: Path, f: Path, default_ns: str, + max_chars: int, overlap: int) -> bool: + rel = f.relative_to(ablage).as_posix() + raw = f.read_bytes() + digest = sha256(raw) + row = con.execute("SELECT sha256 FROM docs WHERE path=?", (rel,)).fetchone() + if row and row[0] == digest: + return False + meta, body = parse_frontmatter(raw.decode("utf-8", errors="replace")) + fields = doc_fields(rel, meta, body, default_ns) + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + with con: + old_ids = [r[0] for r in con.execute("SELECT id FROM chunks WHERE path=?", (rel,))] + if old_ids: + con.executemany("DELETE FROM chunks_vec WHERE rowid=?", [(i,) for i in old_ids]) + con.execute("DELETE FROM chunks WHERE path=?", (rel,)) + con.execute("DELETE FROM verweise WHERE von=?", (rel,)) + con.execute( + "INSERT OR REPLACE INTO docs(path,namespace,projekt,classification,typ,zustand,titel,aktualisiert,sha256,indexiert,eingebettet)" + " VALUES (?,?,?,?,?,?,?,?,?,?,0)", + (rel, fields["namespace"], fields["projekt"], fields["classification"], fields["typ"], + fields["zustand"], fields["titel"], fields["aktualisiert"], digest, now)) + # Titel als eigener erster Chunk, damit er im Volltext gefunden wird + chunks = [fields["titel"]] + chunk_body(body, max_chars, overlap) + con.executemany("INSERT INTO chunks(path,idx,text) VALUES (?,?,?)", + [(rel, i, c) for i, c in enumerate(chunks)]) + con.executemany("INSERT OR IGNORE INTO verweise(von,nach) VALUES (?,?)", + [(rel, v) for v in fields["verweise"]]) + return True + + +def remove_missing(con: sqlite3.Connection, ablage: Path) -> int: + gone = [r[0] for r in con.execute("SELECT path FROM docs") if not (ablage / r[0]).exists()] + with con: + for rel in gone: + ids = [r[0] for r in con.execute("SELECT id FROM chunks WHERE path=?", (rel,))] + con.executemany("DELETE FROM chunks_vec WHERE rowid=?", [(i,) for i in ids]) + con.execute("DELETE FROM docs WHERE path=?", (rel,)) + return len(gone) + + +def embed_pending(con: sqlite3.Connection, emb: Embedder, limit: int | None) -> int: + paths = [r[0] for r in con.execute("SELECT path FROM docs WHERE eingebettet=0 ORDER BY aktualisiert DESC")] + if limit: + paths = paths[:limit] + done = 0 + t0 = time.time() + for n, rel in enumerate(paths, 1): + rows = con.execute("SELECT id, text FROM chunks WHERE path=? ORDER BY idx", (rel,)).fetchall() + vecs = [] + for i in range(0, len(rows), 32): + batch = rows[i:i + 32] + vecs += [(cid, sqlite_vec.serialize_float32(v)) + for (cid, _), v in zip(batch, emb.embed_many([t for _, t in batch]))] + with con: + con.executemany("DELETE FROM chunks_vec WHERE rowid=?", [(cid,) for cid, _ in vecs]) + con.executemany("INSERT INTO chunks_vec(rowid, embedding) VALUES (?,?)", vecs) + con.execute("UPDATE docs SET eingebettet=1 WHERE path=?", (rel,)) + done += len(vecs) + if n % 25 == 0: + print(f" eingebettet: {n}/{len(paths)} dateien, {done} chunks, {time.time() - t0:.0f}s", + file=sys.stderr, flush=True) + return done + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--ablage", required=True, type=Path) + ap.add_argument("--db", type=Path, help="Default: /../km-index.sqlite") + ap.add_argument("--namespace", default=os.environ.get("KM_NAMESPACE", "42i"), + help="Namespace fuer Dateien ohne Angabe im Frontmatter") + ap.add_argument("--max-chars", type=int, default=600) + ap.add_argument("--overlap-chars", type=int, default=60) + ap.add_argument("--embed", action="store_true", help="Vektoren fuer neue/geaenderte Dateien erzeugen") + ap.add_argument("--embed-only", action="store_true", help="nur ausstehende Vektoren nachziehen") + ap.add_argument("--embed-limit", type=int, help="hoechstens so viele Dateien einbetten") + ap.add_argument("--ollama-url", default=os.environ.get("KM_OLLAMA_URL", "http://10.18.5.30:11434")) + ap.add_argument("--embed-model", default=os.environ.get("KM_EMBED_MODEL", "bge-m3")) + a = ap.parse_args() + + db = a.db or (a.ablage.parent / "km-index.sqlite") + con = connect(db) + t0 = time.time() + if not a.embed_only: + files = sorted(p for p in a.ablage.rglob("*.md") if not any(s.startswith(".") for s in p.parts)) + changed = sum(index_file(con, a.ablage, f, a.namespace, a.max_chars, a.overlap_chars) for f in files) + gone = remove_missing(con, a.ablage) + n_chunks = con.execute("SELECT count(*) FROM chunks").fetchone()[0] + print(f"volltext: {len(files)} dateien, {changed} neu/geaendert, {gone} entfernt, " + f"{n_chunks} chunks, {time.time() - t0:.1f}s", file=sys.stderr) + if a.embed or a.embed_only: + t1 = time.time() + n = embed_pending(con, Embedder(a.ollama_url, a.embed_model), a.embed_limit) + pending = con.execute("SELECT count(*) FROM docs WHERE eingebettet=0").fetchone()[0] + print(f"vektoren: {n} chunks eingebettet, {pending} dateien ausstehend, {time.time() - t1:.0f}s", + file=sys.stderr) + con.close() + + +if __name__ == "__main__": + main() diff --git a/image/km/ingest_gitea.py b/image/km/ingest_gitea.py index f53fc13..f9a6e28 100755 --- a/image/km/ingest_gitea.py +++ b/image/km/ingest_gitea.py @@ -80,7 +80,7 @@ class Forge: def get(self, path: str, **params) -> object: for attempt in range(4): - r = self.session.get(f"{self.url}/api/v1{path}", params=params, timeout=60) + r = self.session.get(f"{self.url}/api/v1{path}", params=params, timeout=(10, 60)) if r.status_code in (429, 502, 503, 504) and attempt < 3: time.sleep(2 ** attempt) continue @@ -118,6 +118,22 @@ class Forge: def comments(self, repo: str, number: int) -> list[dict]: return list(self.paged(f"/repos/{repo}/issues/{number}/comments")) + def all_comments(self, repo: str) -> dict[int, list[dict]]: + """Alle Kommentare eines Repos in einem Rutsch, nach Vorgangsnummer. + + Fuer die Erstbefuellung: ein Aufruf je 50 Kommentare statt einer je + Vorgang (live/live: ~300 statt ~2200 Aufrufe). Der Endpunkt liefert + aelteste zuerst, also bleibt die Reihenfolge je Vorgang erhalten. + """ + by_issue: dict[int, list[dict]] = {} + for c in self.paged(f"/repos/{repo}/issues/comments"): + url = c.get("issue_url") or "" + m = re.search(r"/(?:issues|pulls)/(\d+)$", url) + if not m: + continue + by_issue.setdefault(int(m.group(1)), []).append(c) + return by_issue + # ---------------------------------------------------------------- Markdown @@ -230,15 +246,27 @@ def ingest_repo(forge: Forge, repo: str, ablage: Path, state: dict, full: bool) target = ablage / forge.namespace / "vorgaenge" / repo target.mkdir(parents=True, exist_ok=True) seen = written = 0 + # Erstbefuellung: Kommentare repo-weit in einem Rutsch. Inkrementell sind + # es wenige geaenderte Vorgaenge, da ist ein Aufruf je Vorgang billiger. + bulk = forge.all_comments(repo) if since is None else None + t_rep = time.time() for kind in ("issues", "pulls"): for issue in forge.issues(repo, kind, since): seen += 1 - comments = forge.comments(repo, issue["number"]) if issue.get("comments") else [] + n = issue["number"] + if not issue.get("comments"): + comments = [] + elif bulk is not None: + comments = bulk.get(n, []) + else: + comments = forge.comments(repo, n) text = render(forge, repo, issue, comments) - f = target / f"{issue['number']}.md" + f = target / f"{n}.md" if not f.exists() or f.read_text(encoding="utf-8") != text: f.write_text(text, encoding="utf-8") written += 1 + if seen % 100 == 0: + print(f" {repo}: {seen} vorgaenge, {time.time() - t_rep:.0f}s", file=sys.stderr, flush=True) # Stand erst nach vollstaendigem Durchlauf setzen -- bricht der Lauf ab, # holt der naechste dieselben Vorgaenge noch einmal (Wiederholen ist billig, # Luecken sind es nicht). diff --git a/image/km/search.py b/image/km/search.py new file mode 100755 index 0000000..f71d645 --- /dev/null +++ b/image/km/search.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Suche ueber den km-Index: Volltext fuehrend, Vektor ergaenzend (spine/core#1). + + search.py "worktree prune" # hybrid + search.py --mode fts "live/live#2181" # nur Volltext (exakte Treffer) + search.py --mode vec "warum bus ohne jetstream" + search.py --ns 42i --typ vorgang --zustand open "i18n" + +Filter greifen VOR der Suche als WHERE-Klausel -- wer eine Klassifikation +nicht sehen darf, bekommt 0 Treffer, nicht "vorhanden, aber gesperrt" +(spine/core#18). Hybrid = Reciprocal Rank Fusion ueber beide Ranglisten. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sqlite3 +import sys +from pathlib import Path + +import sqlite_vec + +from index import Embedder, connect # noqa: E402 (gleicher Ordner) + + +def fts_query(q: str) -> str: + """Nutzertext -> FTS5-Ausdruck: jedes Wort als Praefix, Sonderzeichen in Anfuehrungszeichen.""" + terms = [] + for tok in re.findall(r"\S+", q): + tok = tok.replace('"', '""') + terms.append(f'"{tok}"' if not tok.isalnum() else f'"{tok}"*') + return " ".join(terms) + + +def where(a, params: list) -> str: + conds = [] + if a.ns: + conds.append("d.namespace = ?"); params.append(a.ns) + if a.projekt: + conds.append("d.projekt = ?"); params.append(a.projekt) + if a.typ: + conds.append("d.typ = ?"); params.append(a.typ) + if a.zustand: + conds.append("d.zustand = ?"); params.append(a.zustand) + if a.classification: + ph = ",".join("?" * len(a.classification)) + conds.append(f"d.classification IN ({ph})"); params.extend(a.classification) + return (" AND " + " AND ".join(conds)) if conds else "" + + +def search_fts(con: sqlite3.Connection, a, k: int) -> list[tuple[int, float]]: + params: list = [fts_query(a.query)] + w = where(a, params) + params.append(k) + rows = con.execute(f""" + SELECT c.id, bm25(chunks_fts) AS score + FROM chunks_fts JOIN chunks c ON c.id = chunks_fts.rowid JOIN docs d ON d.path = c.path + WHERE chunks_fts MATCH ? {w} + ORDER BY score LIMIT ?""", params).fetchall() + return [(r[0], r[1]) for r in rows] + + +def search_vec(con: sqlite3.Connection, a, k: int, emb: Embedder) -> list[tuple[int, float]]: + q = sqlite_vec.serialize_float32(emb.embed(a.query)) + # vec0 kann nicht joinen -- erst k*4 Nachbarn holen, dann filtern + rows = con.execute("SELECT rowid, distance FROM chunks_vec WHERE embedding MATCH ? ORDER BY distance LIMIT ?", + (q, k * 4)).fetchall() + if not rows: + return [] + params: list = [r[0] for r in rows] + w = where(a, params) + ph = ",".join("?" * len(rows)) + ok = {r[0] for r in con.execute(f"SELECT c.id FROM chunks c JOIN docs d ON d.path=c.path WHERE c.id IN ({ph}) {w}", params)} + return [(cid, dist) for cid, dist in rows if cid in ok][:k] + + +def rrf(*lists: list[tuple[int, float]], k: int = 60) -> dict[int, float]: + scores: dict[int, float] = {} + for lst in lists: + for rank, (cid, _) in enumerate(lst, 1): + scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank) + return scores + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("query") + ap.add_argument("--db", type=Path, default=Path(os.environ.get("KM_INDEX", "km-index.sqlite"))) + ap.add_argument("--mode", choices=["hybrid", "fts", "vec"], default="hybrid") + ap.add_argument("--limit", type=int, default=8) + ap.add_argument("--ns"); ap.add_argument("--projekt"); ap.add_argument("--typ"); ap.add_argument("--zustand") + ap.add_argument("--classification", action="append", help="erlaubte Klassifikationen (Sichtkreis), mehrfach") + ap.add_argument("--ollama-url", default=os.environ.get("KM_OLLAMA_URL", "http://10.18.5.30:11434")) + ap.add_argument("--embed-model", default=os.environ.get("KM_EMBED_MODEL", "bge-m3")) + ap.add_argument("--json", action="store_true") + a = ap.parse_args() + + con = connect(a.db) + k = a.limit * 3 + fts = search_fts(con, a, k) if a.mode in ("hybrid", "fts") else [] + vec = search_vec(con, a, k, Embedder(a.ollama_url, a.embed_model)) if a.mode in ("hybrid", "vec") else [] + if a.mode == "fts": + ranked = [(cid, -s) for cid, s in fts] + elif a.mode == "vec": + ranked = [(cid, -d) for cid, d in vec] + else: + ranked = sorted(rrf(fts, vec).items(), key=lambda x: -x[1]) + + # ein Treffer je Dokument, bester Chunk zaehlt + out, seen = [], set() + for cid, score in ranked: + row = con.execute("SELECT c.path, c.idx, c.text, d.titel, d.typ, d.zustand, d.aktualisiert " + "FROM chunks c JOIN docs d ON d.path=c.path WHERE c.id=?", (cid,)).fetchone() + if not row or row[0] in seen: + continue + seen.add(row[0]) + snippet = re.sub(r"\s+", " ", row[2])[:240] + out.append({"path": row[0], "titel": row[3], "typ": row[4], "zustand": row[5], + "aktualisiert": row[6], "score": round(score, 4), "chunk": row[1], "snippet": snippet}) + if len(out) >= a.limit: + break + + if a.json: + print(json.dumps(out, ensure_ascii=False, indent=1)) + else: + for r in out: + print(f"{r['score']:>8} {r['path']} [{r['typ']}/{r['zustand'] or '-'}] {r['titel']}\n" + f" {r['snippet']}") + if not out: + print("0 Treffer", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/image/requirements.txt b/image/requirements.txt index 535409c..1250592 100644 --- a/image/requirements.txt +++ b/image/requirements.txt @@ -1 +1,2 @@ requests>=2.31 +sqlite-vec>=0.1.6