Files
tibi-docs/src/content/docs/projekt/suche.mdx
T
apairon 7f15ab5384
Deploy tibi-docs / build-and-deploy (push) Failing after 31s
Suche: _search-Lesezugriffe im Hook-Fallback nennen
2026-10-20 00:22:03 +00:00

248 lines
20 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: 'Suche'
description: 'Textindex, search-Blöcke, Hybrid-Suche, Neuaufbau des Index aus Projektsicht.'
---
import { Aside, Steps } from '@astrojs/starlight/components';
Die Suche läuft über `search`-Blöcke in der Sammlungs-YAML plus Abfrage per `q` und `qName`.
Suchbegriffe: Volltextsuche, Hybrid-Suche, Suchindex neu aufbauen.
## Index anlegen
`content.yml` definiert drei Indexe:
| Index | Schlüssel | Zweck |
|---|---|---|
| `content_text` | `$text:$**`, `defaultLanguage: none` | Volltext über alle Felder |
| `path_lang` | `path` + `lang`, unique | Pfad je Sprache eindeutig |
| `translation` | `translationKey` + `lang`, unique | Übersetzung je Sprache eindeutig |
`navigation.yml` und `medialib.yml` nutzen nur einen `default`-Index im Modus `regex` (kein Textindex nötig).
## search-Blöcke
```yaml title="content.yml — search-Blöcke (gekürzt)"
# content.yml — search (gekürzt)
search:
- name: combined # Hybrid aus semantic + fuzzy
mode: combined
rrf: { k: 60, topK: 100, weights: { semantic: 1.5, fuzzy: 1.0 } }
- name: fuzzy
mode: ngram
autoRegenerate: true
fields: [name, path, meta.title, meta.description, blocks.headline, blocks.subline,
blocks.text, blocks.items.title, blocks.items.text, blocks.items.question, blocks.items.answer]
- name: semantic
mode: vector
autoRegenerate: true
fields: [name, meta.title, meta.description, blocks.headline, blocks.subline,
blocks.text, blocks.items.title, blocks.items.text, blocks.items.question, blocks.items.answer]
vector: { provider: bge_m3 }
```
| `qName` | Sammlung | Modus |
|---|---|---|
| `combined` | `content` | Hybrid (RRF aus `semantic` + `fuzzy`) |
| `fuzzy` | `content` | n-gram, fehlertolerant |
| `semantic` | `content` | Vektor (`bge_m3`) |
| `default` | `tag`, `navigation`, `comment`, `medialib` | regex |
## Lesen mit q
Grundlagen (Filter, Lookup, Facetten) stehen unter [Lesen und suchen](/projekt/abfragen-lesen/). Hier nur die Suche:
```text title="Suche per q und qName"
GET …/content?q=kontakt&qName=combined
GET …/tag?q=hero
GET …/content?q=kontakt&qName=combined&qExplain=1
```
- `q` ist der Suchtext, `qName` wählt den Block (ohne `qName` greift der Standard).
- `qExplain=1` zeigt die Gewichtung — nützlich beim Debuggen.
- Suche mit und ohne `lookup` testen: `?q=…&qName=combined&lookup=blocks.heroImage.image:medialib,blocks.image:medialib` löst Treffer-Bilder gleich auf.
## Hybrid-Kurzüberblick
`combined` mischt Vektor- und n-gram-Treffer per Reciprocal Rank Fusion (`internal/rankfusion`, tibi-server):
```text title="RRF-Formel (combined)"
score(doc) += weight / (k + rank) # k = 60, weights: semantic 1.5, fuzzy 1.0
```
- `topK: 100` begrenzt die Kandidaten je Quelle **und** kappt das Fusionsergebnis (`MergeRRF`).
- Weitere Strategien (`score_rrf`, `score_sum`, `rank_score`, `hybrid`), Normierung (`minmax`, `zscore`), Gewichte (`scoreWeight`) und Quellen-Config (`sourceConfig`) existieren im Code **und** sind als `rrf`-Block in der Projekt-YAML einstellbar — aus Projektsicht reicht `combined` mit den Starter-Gewichten.
- Alte Score-Beispiele aus Erzähl-Docs nicht übernehmen — nur diese Formel gilt.
## Suchmodi im Überblick
Alle Modi aus `collection.go:314` — `fields` gilt für `regex` und `ngram`, `vector.provider` muss auf einen Server-Provider zeigen (`collection.go:377`):
| Modus | Einsatzfall | Braucht Index/Aufbau |
|---|---|---|
| `text` | MongoDB-Volltext, eine Textstelle pro Sammlung | Ja — Mongo-Textindex (`hasTextIndex`; nur **ein** Textindex je Sammlung) |
| `regex` | Einfache Teilübereinstimmung (`default`-Block in `tag`, `navigation`, `comment`, `medialib`) | Nein — läuft direkt auf den Feldern, kein Aufbau, keine Queue |
| `eval` | Eigene JS-Bewertung pro Treffer (`eval`-Ausdruck) | Nein — Ausdruck läuft zur Abfragezeit, optional `score.expr` |
| `filter` | Feste MongoDB-Filtervorlage mit `$subst` | Nein — Filter zur Abfragezeit, optional `score.expr` |
| `ngram` | Fehlertolerante Teilwortsuche (Starter-`fuzzy`) | Ja — `_search`-Anreicherung mit `autoRegenerate`, Queue bis zum Worker-Lauf |
| `vector` | Semantische Suche (Starter-`semantic`, Provider `bge_m3`) | Ja — Embeddings über Embedding-Provider, Queue bis zum Worker-Lauf |
| `combined` | Hybrid aus mehreren Blöcken (Starter-`combined` = RRF aus `semantic` + `fuzzy`) | Je beteiligtem Block (siehe oben); Fusion selbst braucht keinen eigenen Index |
<Aside type="note">
`qName` wählt den Block, `q` liefert den Suchtext. Ohne `qName` greift der erste Block der Sammlung. `text`, `eval` und `filter` kommen im Starter nicht vor — dort zählen `regex` (kein Aufbau), `ngram`/`vector` (Aufbau nötig) und `combined` (Fusion).
</Aside>
## Index aufbauen und prüfen
<Steps>
1. Bearbeite Einträge normal — `fuzzy` und `semantic` haben `autoRegenerate: true` und bauen sich selbst nach.
2. Beobachte im tibi-admin den Hinweis `SearchIndexBuildNotice`: Er erscheint im Projekt-Header (Summe offener `_queue`-Einträge aller `ngram`-/`vector`-Sammlungen) und im Sammlungs-Header der betroffenen Sammlung.
3. Warte, bis der Hinweis nach dem nächsten Polling von selbst verschwindet (`_queue` = 0). Der `regex`-Modus (`default`) hat keine Queue und keinen Hinweis.
4. Bleibt die Queue stehen oder hast du die Config geändert: `POST /_/:project/_/admin/regenerate-search` aufrufen — optional mit Body `{ collection, modes, names, ids, filter, force }` (`admin_regenerate_search.go:17-25`). Mit `?async=1` nur einreihen und sofort antworten (`{"message": "queued", …}`), ohne `filter` auch synchron möglich. Ungültige IDs ohne einzige gültige Hex-ID brechen mit `400 no valid document IDs in request` ab.
5. Status prüfen: `GET /_/:project/_/admin/search-worker/status` zeigt pro Provider Aktiv-Flag, Queue-Tiefe, laufenden Job und Fortschrittszähler (`admin_search_status.go:10-15`). Auth jeweils: Scope `project.regenerate-search` plus Token-Flag `AllowRegenerateSearch`.
</Steps>
<Aside type="note" title="Neuaufbau aus Sicht des Projekts">
Aus Projektsicht musst du nichts einrichten — lass `autoRegenerate: true` in der YAML stehen, dann pflegen sich `fuzzy`- und `semantic`-Indizes selbst. Der Regenerate-Flow im tibi-admin (Test-Plan `41-search-index-status`) nutzt genau den Endpunkt aus Schritt 4.
</Aside>
## Stoppwörter für `ngram` (`ngram.stopwords`)
Füllwörter wie „zum", „der" oder „für" treffen in einer n-gram-Suche jeden Text, in dem sie irgendwo vorkommen — auch Beschreibungen, in denen sie nichts aussagen. `ngram.stopwords` entfernt sie aus **beiden** Seiten: aus den gespeicherten N-Grammen und aus der Suchanfrage. Einseitig wäre falsch: stehen gebliebene Stoppwort-N-Gramme blähen die Ähnlichkeitswerte auf und drücken die Unterschiede zwischen relevanten und irrelevanten Treffern zusammen.
```yaml title="Projekt-YAML — Stoppwörter für fuzzy"
search:
- name: fuzzy
mode: ngram
fields: [name, meta.title, meta.description]
ngram:
sizes: [2, 3]
stopwords:
presets: [de] # de, en, fr, es, it
words: [zum, zur] # optionale Ergänzungen, werden gemerged
```
- Die Presets enthalten reine Funktionswörter: Artikel, Pronomen, Präpositionen, Konjunktionen, Hilfsverben und bedeutungsfreie Adverbien (`internal/utils/ngram_stopwords.go`). Negationswörter wie „nicht" oder „kein" bleiben bewusst draußen — „nicht wasserdicht" würde sonst zur Suche nach „wasserdicht".
- Einträge werden mit derselben Normalisiererkette gebildet wie Dokumente und Anfragen. Ohne `normalize`-Block laufen alle Zusatznormalisierer, aus „für" wird `fuer`; nur mit `normalize.use: []` bleibt es beim Standard-Normalisierer und damit bei `fur`. Einträge, die nicht genau ein Wort ergeben, erzeugen einen Projekt-Fehler (`internal/models/collection_setup.go`).
- `stopwords` gilt nur für `mode: ngram`. Ein Block mit anderem Modus und gesetzten Stoppwörtern erzeugt einen Projekt-Fehler; das Projekt lädt trotzdem, die Stoppwörter bleiben in diesem Fall aber aus.
- Ein Set gilt für den ganzen Block, nicht je Sprache: Es wirkt auf jedes Wort in den konfigurierten Feldern. Bei gemischtsprachigen Inhalten lieber `words` statt Presets nutzen oder einen Block je Sprache anlegen — sonst verschwinden Wörter der falschen Sprache (etwa `car`/`or` aus deutschen Texten, wenn `fr` aktiv ist). `words` ergänzt das Set nur; ein Preset-Wort lässt sich damit nicht wieder freistellen.
- Eine Anfrage, die nur aus Stoppwörtern besteht (oder nur aus Begriffen kürzer als jede konfigurierte Größe), liefert **null Treffer** statt der ganzen Sammlung (`internal/utils/ngram.go`).
### Nach Änderungen neu aufbauen
`ngram.stopwords` ist Teil des Anreicherungs-Hashs. Hinzufügen ist unkritisch: Die Anfrage verwirft die Wörter, die Dokumente bleiben über ihre übrigen Begriffe auffindbar. Scores können bis zum Neuaufbau abweichen, weil Kreuz-Wort-N-Gramme ohne die Stoppwort-Lücke neu entstehen. **Entfernen** braucht den Neuaufbau zuerst — sonst liefern diese Wörter null Treffer, bis jedes Dokument neu angereichert ist:
<Steps>
1. `stopwords` in der Projekt-YAML ändern und das Projekt neu laden.
2. `POST /_/:project/_/admin/regenerate-search` mit `{ collection, modes: ["ngram"] }` aufrufen — oder `autoRegenerate: true` setzen und den Reload abwarten.
3. `GET /_/:project/:collection/_queue` beobachten, bis der Zähler 0 ist. Erst danach die alten Wörter aus der Suche nehmen.
</Steps>
<Aside type="note">
Mehrere Server-Instanzen brauchen dieselbe Konfiguration: Ein Knoten ohne (oder mit anderen) Stoppwörtern schreibt einen anderen Config-Hash, markiert die Dokumente der anderen Knoten als veraltet und die `_queue` läuft nie leer.
Ein Server-Update ohne `stopwords`-Konfiguration ändert nichts: Configs ohne Stoppwörter behalten ihren bisherigen Hash und werden nicht neu aufgebaut. Während ein Neuaufbau läuft, kann ein In-Memory-Index noch N-Gramme nicht regenerierter Dokumente halten; die Treffer nähern sich mit dem Fortschritt an, ein Projekt-Reload baut den Index neu auf.
</Aside>
## Performance-Optionen (Cache und In-Memory-Index)
Beide Optionen sind **standardmäßig aus** und ändern ohne Konfiguration nichts.
### Ergebnis-Cache für `combined` (`combinedCache`)
Wiederholte Combined-Queries (Katalog-Filter, Blättern durch dieselbe Treffermenge) können die gemergte Rangliste kurzzeitig wiederverwenden:
```yaml
search:
- name: combined
mode: combined
rrf: { k: 60, topK: 100, weights: { semantic: 1.5, fuzzy: 1.0 } }
combinedCache:
enabled: true # Default: false
ttl: 45s # Default: 45s
maxEntries: 1000 # LRU-Cap, Default: 1000
```
- Gecacht werden nur die gemergten **IDs + Scores**; die Dokumente der Seite werden immer frisch geladen.
- Der Key enthält Query, Config, den effektiven Filter (inkl. Rollen-`preFilter` und Hook-Filter) und alle Fusionsparameter — Ergebnisse können nicht zwischen Rollen oder Sammlungen wandern.
- Aktiv: `X-Cache: HIT|MISS` im Response-Header. Bei `HIT` bleiben `X-Search-Sources`/`X-Search-Config` leer (es lief keine Subsearch).
- Schreibvorgänge, die einen In-Memory-Index (Vektor oder Ngram) verändern, invalidieren den Cache sofort (der Mutationszähler des Index steckt im Key); die `ttl` ist die Obergrenze für Änderungen, die noch nicht im Index stehen. Nach einem Restore wird der Cache verworfen.
- Grenzen: `ttl` kurz halten (≤30s bei mehreren Server-Instanzen, die Invalidierung ist node-lokal).
### In-Memory-Index für `ngram` (`ngram.inMemory`)
Für große Sammlungen kann der ngram-Anteil einer Combined-Suche aus dem RAM beantwortet werden statt aus MongoDB:
```yaml
# Server-config.yml — globaler Schalter, ohne ihn passiert nichts
search:
inMemory:
enabled: true
maxMemoryMB: 1024
# Projekt-YAML
search:
- name: fuzzy
mode: ngram
fields: [title, context]
ngram:
sizes: [3]
queryFilter: { minMatchRatio: 0.4 }
inMemory:
enabled: true # Default: false
maxDocs: 150000 # mehr Dokumente → MongoDB
maxMemoryMB: 512 # Budget pro Index
```
- Gebaut wird der Index beim Projekt-(Re-)Load aus den **gespeicherten** `_search`-Ngrammen (nie aus den Quellfeldern), damit er nicht vom MongoDB-Pfad abweichen kann.
- Genutzt wird er für ngram-Quellen von `combined`-Suchen **und für Einzel-ngram-Suchen** — dort ersetzt er nur das Ranking, die Seitendokumente werden anschließend per `_id` geladen (Projektion, `lookup` und Aggregation bleiben also nutzbar). Bei `combined` zeigt der Debug-Header `inMemory=ram|mongo(loading)|mongo(budget)|mongo(candidates)|mongo(no-terms)`; `mongo(no-terms)` erscheint nur, wenn für die Quelle überhaupt ein In-Memory-Index existiert.
- Einzel-ngram-Suchen fallen mit `X-Search-Debug: true` im Kopf `X-Search-Memory` sichtbar auf MongoDB zurück, wenn der Index nicht bereit ist oder die Anfrage etwas braucht, das der Index nicht garantieren kann: `mongo(disabled)`, `mongo(loading)`, `mongo(budget)`, `mongo(count)` (bei `?count=1`), `mongo(sort)` (bei eigenem `?sort=`), `mongo(no-terms)` — sowie `mongo(lookup)`, `mongo(aggregate)` und `mongo(hook-pipeline)`, wenn die Anfrage Schritte braucht, die in der MongoDB-Pipeline laufen. Ein `pipelineMod` aus einem Read-Hook stört den RAM-Pfad nur, wenn er die Ergebnismenge ändern kann (`$match`, `$sort`, `$group`, `$project`, …), Stufen vor die bestehende Pipeline setzt oder `_id`/`_search` schreibt bzw. `$_search` liest. Reine angehängte Feld-Transformationen (`$addFields`, `$set`, `$unset`) laufen auf der geladenen Seite nach der Projektion und bleiben im RAM.
- Rollen-`preFilter`, `?filter=` und Hook-Filter gelten weiter: der Index liefert Kandidaten-IDs, die danach gegen den effektiven Filter geprüft werden.
- Schreibvorgänge (Anlegen/Ändern/Löschen/Bulk/Worker/Regenerate) halten den Index aktuell; **Snapshot- und Backup-Restore bauen alle In-Memory-Zustände neu auf** (Indizes + Cache) — alte Treffer können einen Restore nicht überleben. Während des Neuaufbaus laufen Suchen auf MongoDB.
- Budget-Abbruch (zu viele Dokumente, Speicher- oder RSS-Grenze) lässt den Index leer und dauerhaft auf MongoDB; ein Reload startet einen neuen Versuch.
- Gemessen (91k Dokumente): Die ngram-Subsearch von „blauer pullover" fällt von ~1,3 s auf wenige Millisekunden; Einzelwort-Suchen von ~1,4–4,9 s auf 85–158 ms. Das kompakte Index-Layout kostet ≈155 MB statt ≈1,15 GB und lädt in ≈2,7 s.
- Der 20000er-Kandidaten-Cap gilt nur für `combined` (dort auf Roh-Kandidaten). Einzel-ngram-Suchen prüfen nur so viele Kandidaten in Rangfolge, wie die Seite braucht (Blöcke à 500 `_id`s).
## Vektor-Index: Laden, Speicher, Altformate
Der Vektor-Index wird beim Projekt-Start im Hintergrund aufgebaut; bis dahin liefern Suchen Teilergebnisse. Für 91k Dokumente mit 1024 Dimensionen liegt der Load bei ~14 s (count-balancierte `_id`-Bereiche, parallel dekodiert) und der Speicher bei ~690 MB pro Config und Sprache. Die Sparse-Suche ist mit dem kompakten Layout von ~190 ms auf ~4 ms gefallen, Dense bleibt bei ~5 ms (parallel über die Kerne).
Zwei Dense-Speicherformen sind im Umlauf: BSON-Binary (aktuell) und ein Legacy-Byte-Array (4× größer, ~5× langsamer beim Laden). Der Server liest beide; für Altbestände gibt es im Server-Repo `tools/vectormigrate` (erst `-dry-run`, danach Projekt neu laden). Ein MongoDB-Fallback existiert nicht: fehlt der Index, bricht die Vektor-Suche mit einem klaren Fehler ab.
## Was der Vektor-Pfad zusätzlich braucht
- **Provider-Name:** `vector.provider` (z. B. `bge_m3`) muss mit einem Servernamen aus der Embedding-Registry übereinstimmen (`collection.go:377`) — sonst löst die Config nicht auf. Unbekannte Provider-Typen brechen ab, defekte einzelne Provider werden übersprungen (`registry.go:58-70`).
- **Gesundheit prüfen:** `GET /_/:project/_/admin/embedding/status` zeigt Gruppen-/Mitglieder-Gesundheit (an diesem Scope sind Fehlertexte redigiert, nur Zustände und Zähler; Volldetail nur am Server-Endpunkt `GET /api/v1/admin/embedding/status`, `admin_embedding_status.go:49-72`).
- **Feinheiten (optional):** `documentPrefix`/`queryPrefix`, `overflow: truncate` (Standard) oder `chunk` mit `chunkSize` (Standard 512) und `chunkOverlap` (Standard 50) — `collection.go:376-383`.
- **Debug:** Mit Header `X-Search-Debug: true` antwortet die Suche mit `X-Search-Config`, `X-Search-Timing` und `X-Search-Sources` (Treffer-/Score-Spannen je Quelle, `search_debug.go:145-159`).
## Fehlerbilder
| Symptom | Ursache | Lösung |
|---|---|---|
| Leere Scores / keine Treffer trotz Daten | Index fehlt oder `_queue` noch > 0 | Hinweis im Admin abwarten, dann erneut suchen |
| `combined` findet nichts, `fuzzy` schon | Vektor-Anteil ohne Embeddings | `semantic`-Felder und Provider (`bge_m3`) prüfen |
| `400 no valid document IDs in request` | Regenerate mit nur ungültigen `ids` | Hex-IDs prüfen oder `ids` weglassen (Vollaufbau) |
| `404 collection "…" not found` | Regenerate mit falschem `collection`-Namen | Namen gegen `api/collections/*.yml` prüfen |
| Worker-Queue steht | Provider defekt oder überlastet | `search-worker/status` und `embedding/status` lesen, dann Regenerate mit `force: true` |
| Suche über `tag`/`navigation` unscharf | `default` ist `regex`, kein Fuzzy | Erwartung anpassen oder `content`-Indexe nutzen |
| `inMemory=ram` erscheint nie im Debug-Header | Globaler Schalter aus, Config ohne `inMemory`, Index noch nicht fertig oder Budget-Abbruch | `search.inMemory.enabled`, `ngram.inMemory.enabled` prüfen, Reload, Budget-Logs lesen |
| Suche mit einem einzelnen Füllwort liefert nichts | `ngram.stopwords` verwirft das Wort aus der Anfrage | Inhaltliches Wort suchen; soll das Wort suchbar bleiben, das Preset weglassen und das Set nur über `words` bauen |
| Ein entferntes Stoppwort liefert weiterhin nichts | Neuaufbau nach dem Entfernen fehlt oder läuft noch | `regenerate-search` anstoßen, `_queue` auf 0 warten |
| `mongo(no-terms)` in `X-Search-Sources` | Combined-Quelle ohne verwertbare Terme (nur Stoppwörter oder zu kurze Begriffe) | Kein Fehler — Anfrage präzisieren |
| `X-Search-Memory: mongo(disabled)` bei jeder Suche | In-Memory-Index nicht aktiviert oder nicht geladen | `search.inMemory.enabled` und `ngram.inMemory.enabled` prüfen, Projekt neu laden |
| `X-Search-Memory: mongo(budget)` | Index-Load über dem Budget abgebrochen (Doc-Anzahl, Speicher oder RSS) | Budget erhöhen oder Collection kleiner halten; Loader-Log lesen |
| `X-Search-Memory: mongo(sort)` / `mongo(count)` | Eigenes `?sort=` bzw. `?count=1` — der Index kann das nicht garantieren | Erwartetes Verhalten; ohne `sort`/`count` messen |
| `X-Search-Memory: mongo(lookup)` / `mongo(aggregate)` / `mongo(hook-pipeline)` | Anfrage nutzt Schritte, die in der MongoDB-Pipeline laufen | Erwartetes Verhalten; ohne `lookup`/`aggregate`-Parameter messen |
| Nach Restore kurz alte Treffer | Restore erfolgt, Index-Neuaufbau läuft noch | Warten bis `inMemory=ram` wieder erscheint (bis dahin MongoDB-Fallback) |
| Combined-Suche liefert kurz eine ältere Reihenfolge | `combinedCache` aktiv, TTL noch nicht abgelaufen | TTL senken oder Cache für kritische Daten abschalten |
| `?facet=` an der Suche wirkungslos | Facetten sind eigene Route (`…/_facet`) | [Lesen und suchen](/projekt/abfragen-lesen/) beachten |
Weiter: [Lesen und suchen](/projekt/abfragen-lesen/), [Sammlungen anlegen](/projekt/sammlungen-anlegen/), [Felder und Widgets](/projekt/felder-widgets/).
Verifiziert am e3f92eb 2026-10-14 (tibi-svelte-starter), am 68b4278 2026-10-15 (tibi-server, rankfusion), am 2b091d8 2026-10-13 (tibi-admin-nova, Test-Plan 41-search-index-status), am 63a2848 2026-10-19 (tibi-server: combinedCache, ngram.inMemory, _count-Fast-Path, Restore-Rebuild), am 76f1336 2026-10-19 (tibi-server: ngram.stopwords, Regenerate-Endpunkt, mongo(no-terms)); am 2a8922eb 2026-10-19 (tibi-server: kompakter ngramindex, collection_ngram_search.go, Hook-Pipeline-Klassifikation, X-Search-Memory), am 654b568 2026-10-19 (tibi-server: kompakter Sparse-Vektorindex, Legacy-Dense-Format, MemoryEstimate, vectormigrate, paralleler Range-Loader inkl. Retry/Semaphore), am 9e12a8e 2026-10-19 (tibi-types: ngram.stopwords/queryFilter/inMemory im Schema), am 21472bd 2026-10-19 (tibi-types: ngram.sizes 2–10, combinedCache im Schema), am cf44cd7 2026-10-19 (tibi-svelte-starter: tibi-types-Submodul auf 21472bd)