Baue den Index als SQLite mit FTS5 und sqlite-vec, dazu die Suche

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 18:04:35 +02:00
co-authored by Claude Fable 5.1
parent 1e08de2d05
commit 4986ef0575
9 changed files with 599 additions and 14 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.venv/
+89
View File
@@ -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`).
+10 -8
View File
@@ -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
+4 -3
View File
@@ -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:
Binary file not shown.
+324
View File
@@ -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: <ablage>/../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()
+31 -3
View File
@@ -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).
+137
View File
@@ -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()
+1
View File
@@ -1 +1,2 @@
requests>=2.31
sqlite-vec>=0.1.6