neue chunker, Fehler und Strategie Korrektur

This commit is contained in:
Lars 2025-12-16 12:07:28 +01:00
parent cf49715c66
commit c61b66b49d
3 changed files with 197 additions and 127 deletions

View File

@ -1,7 +1,7 @@
"""
FILE: app/core/chunker.py
DESCRIPTION: Zerlegt Texte in Chunks (Sliding Window oder nach Headings). Orchestriert die Smart-Edge-Allocation via SemanticAnalyzer.
VERSION: 2.6.0 (Fix: Strict Heading Split & Header Retention)
VERSION: 2.9.0 (Feat: Hybrid Strict Splitting with Size Safety)
STATUS: Active
DEPENDENCIES: app.services.semantic_analyzer, app.core.derive_edges, markdown_it, yaml, asyncio
EXTERNAL_CONFIG: config/types.yaml
@ -25,7 +25,7 @@ from app.services.semantic_analyzer import get_semantic_analyzer
try:
from app.core.derive_edges import build_edges_for_note
except ImportError:
# Mock für Tests, falls Module fehlen
# Mock für Tests
def build_edges_for_note(note_id, chunks, note_level_references=None, include_note_scope_refs=False): return []
logger = logging.getLogger(__name__)
@ -122,10 +122,14 @@ def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]:
for line in lines:
stripped = line.strip()
if stripped.startswith('# '):
# H1 wird für den Titel genutzt, aber nicht als Block für sliding window
# (Außer es ist H1 im Body, aber wir ignorieren H1 hier meist als Title)
continue
if stripped.startswith('# '):
if buffer:
content = "\n".join(buffer).strip()
if content:
blocks.append(RawBlock("paragraph", content, None, section_path, current_h2))
buffer = []
blocks.append(RawBlock("heading", stripped, 1, section_path, current_h2))
elif stripped.startswith('## '):
if buffer:
content = "\n".join(buffer).strip()
@ -134,8 +138,16 @@ def parse_blocks(md_text: str) -> Tuple[List[RawBlock], str]:
buffer = []
current_h2 = stripped[3:].strip()
section_path = f"/{current_h2}"
# WICHTIG: Die Überschrift selbst als Block speichern!
blocks.append(RawBlock("heading", stripped, 2, section_path, current_h2))
elif stripped.startswith('### '):
if buffer:
content = "\n".join(buffer).strip()
if content:
blocks.append(RawBlock("paragraph", content, None, section_path, current_h2))
buffer = []
blocks.append(RawBlock("heading", stripped, 3, section_path, current_h2))
elif not stripped:
if buffer:
content = "\n".join(buffer).strip()
@ -175,19 +187,18 @@ def _strategy_sliding_window(blocks: List[RawBlock], config: Dict[str, Any], not
text_body = "\n\n".join([b.text for b in buf])
win_body = f"{context_prefix}\n{text_body}".strip() if context_prefix else text_body
# Basis-Info vom ersten Block im Buffer
sec = buf[0].section_title if buf else None
path = buf[0].section_path if buf else "/"
if estimate_tokens(text_body) <= max_tokens:
sec = buf[0].section_title if buf else None
path = buf[0].section_path if buf else "/"
_create_chunk_obj(chunks, note_id, text_body, win_body, sec, path)
else:
# Fallback: Wenn Block zu groß, intern splitten (Sentence-Level)
sentences = split_sentences(text_body)
current_chunk_sents = []
current_len = 0
# Basis-Info vom ersten Block im Buffer
sec = buf[0].section_title if buf else None
path = buf[0].section_path if buf else "/"
for sent in sentences:
sent_len = estimate_tokens(sent)
if current_len + sent_len > target and current_chunk_sents:
@ -219,11 +230,7 @@ def _strategy_sliding_window(blocks: List[RawBlock], config: Dict[str, Any], not
buf = []
for b in blocks:
# Bei Sliding Window ignorieren wir Heading-Blocks als Split-Trigger NICHT zwingend,
# aber wir wollen Headings oft nicht "allein" stehen haben.
# Hier einfache Logik:
if b.kind == "heading":
# Optional: Buffer flushen bei neuem Header, um Kontextwechsel sauberer zu machen
flush_buffer()
current_buf_text = "\n\n".join([x.text for x in buf])
@ -237,30 +244,34 @@ def _strategy_sliding_window(blocks: List[RawBlock], config: Dict[str, Any], not
def _strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id: str, doc_title: str = "") -> List[Chunk]:
"""
STRICT HEADING SPLIT (Fix v2.6.0):
Trennt den Text konsequent an jeder Überschrift der definierten Ebene.
Behält Überschriften als Teil (erste Zeile) des Chunks bei.
Kein Merging kleiner Abschnitte über Header-Grenzen hinweg.
MODUS: Structured / Heading Split
- split_level: Ebene für logische Trennung (z.B. H2).
- strict_heading_split:
True: Trennt an jedem Header <= split_level.
NEU v2.9: Wenn Inhalt > max_tokens, wird trotzdem gesplittet (Safety Split).
False: Fasst zusammen bis 'target' erreicht ist.
"""
split_level = config.get("split_level", 2)
chunks = []
target = config.get("target", 400)
max_limit = config.get("max", 600)
strict_mode = config.get("strict_heading_split", False)
# Temporärer Speicher für den aktuellen Chunk
chunks = []
current_chunk_blocks = []
context_prefix = f"# {doc_title}"
def has_content(blk_list):
return any(b.kind != "heading" for b in blk_list)
def flush_current_chunk():
nonlocal current_chunk_blocks
if not current_chunk_blocks:
return
# Text zusammenbauen
text_body = "\n\n".join([b.text for b in current_chunk_blocks])
# Window bauen (hier einfach Text, da Kontext via Header implizit ist)
win_body = f"{context_prefix}\n{text_body}".strip()
# Metadaten vom ersten Block (üblicherweise der Header) nehmen
first_b = current_chunk_blocks[0]
sec = first_b.section_title
path = first_b.section_path
@ -268,19 +279,49 @@ def _strategy_by_heading(blocks: List[RawBlock], config: Dict[str, Any], note_id
_create_chunk_obj(chunks, note_id, text_body, win_body, sec, path)
current_chunk_blocks = []
def get_current_size():
txt = "\n\n".join([b.text for b in current_chunk_blocks])
return estimate_tokens(txt)
for b in blocks:
# Prüfen, ob dieser Block ein Trenner (Header auf Split-Level) ist
is_splitter = (b.kind == "heading" and b.level == split_level)
# 1. Header Logic (Struktur-Trigger)
is_splitter = (b.kind == "heading" and b.level is not None and b.level <= split_level)
if is_splitter:
# 1. Den bisherigen Chunk abschließen (falls vorhanden)
flush_current_chunk()
is_higher_hierarchy = (b.level < split_level)
# 2. Den neuen Chunk mit diesem Header beginnen
current_chunk_blocks.append(b)
if strict_mode:
# STRICT:
# Wir splitten immer, außer der Vor-Chunk ist leer.
if current_chunk_blocks and has_content(current_chunk_blocks):
flush_current_chunk()
current_chunk_blocks.append(b)
else:
# SOFT:
# Split bei Hierarchie-Wechsel ODER wenn voll.
if is_higher_hierarchy:
flush_current_chunk()
current_chunk_blocks.append(b)
elif current_chunk_blocks and get_current_size() >= target:
flush_current_chunk()
current_chunk_blocks.append(b)
else:
current_chunk_blocks.append(b)
else:
# Einfach anhängen
current_chunk_blocks.append(b)
# 2. Content Logic (Safety Trigger für Monster-Abschnitte)
# Bevor wir den Block anhängen: Würde er das Fass zum Überlaufen bringen?
# Wir nutzen hier 'max' als harte Grenze für den Safety-Split.
current_size = get_current_size()
block_size = estimate_tokens(b.text)
if current_chunk_blocks and (current_size + block_size > max_limit):
# NOTBREMSE: Chunk wird zu groß.
# Wir splitten hier, auch wenn kein Header da ist.
# Der Kontext (Section Title) bleibt erhalten, da er aus `current_h2` kommt (siehe parse_blocks).
flush_current_chunk()
current_chunk_blocks.append(b)
else:
current_chunk_blocks.append(b)
# Letzten Rest flushen
flush_current_chunk()
@ -301,14 +342,12 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op
primary_strategy = config.get("strategy", "sliding_window")
enable_smart_edges = config.get("enable_smart_edge_allocation", False)
# Performance/Cost-Guard: Bei Entwürfen keine Smart Edges
if enable_smart_edges and note_status in ["draft", "initial_gen"]:
logger.info(f"Chunker: Skipping Smart Edges for draft '{note_id}'.")
enable_smart_edges = False
blocks, doc_title = parse_blocks(md_text)
# Strategie-Auswahl
if primary_strategy == "by_heading":
chunks = await asyncio.to_thread(_strategy_by_heading, blocks, config, note_id, doc_title)
else:
@ -317,11 +356,9 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op
if not chunks:
return []
# Smart Edge Allocation (WP-15)
if enable_smart_edges:
chunks = await _run_smart_edge_allocation(chunks, md_text, note_id, note_type)
# Verkettung der Chunks (next/prev)
for i, ch in enumerate(chunks):
ch.neighbors_prev = chunks[i-1].id if i > 0 else None
ch.neighbors_next = chunks[i+1].id if i < len(chunks)-1 else None
@ -329,10 +366,6 @@ async def assemble_chunks(note_id: str, md_text: str, note_type: str, config: Op
return chunks
def _extract_all_edges_from_md(md_text: str, note_id: str, note_type: str) -> List[str]:
"""
Hilfsfunktion: Erstellt einen Dummy-Chunk für den gesamten Text und ruft
den Edge-Parser auf, um ALLE Kanten der Notiz zu finden.
"""
dummy_chunk = {
"chunk_id": f"{note_id}#full",
"text": md_text,
@ -340,7 +373,6 @@ def _extract_all_edges_from_md(md_text: str, note_id: str, note_type: str) -> Li
"window": md_text,
"type": note_type
}
# Parsing aller Kanten (Inline, Wikilinks, Callouts)
raw_edges = build_edges_for_note(
note_id,
[dummy_chunk],
@ -351,29 +383,23 @@ def _extract_all_edges_from_md(md_text: str, note_id: str, note_type: str) -> Li
for e in raw_edges:
kind = e.get("kind")
target = e.get("target_id")
# Struktur-Kanten ignorieren wir für die Verteilung
if target and kind not in ["belongs_to", "next", "prev", "backlink"]:
all_candidates.add(f"{kind}:{target}")
return list(all_candidates)
async def _run_smart_edge_allocation(chunks: List[Chunk], full_text: str, note_id: str, note_type: str) -> List[Chunk]:
analyzer = get_semantic_analyzer()
# A. Alle potenziellen Kanten der Notiz sammeln
candidate_list = _extract_all_edges_from_md(full_text, note_id, note_type)
if not candidate_list:
return chunks
# B. LLM Filterung pro Chunk (Parallel)
tasks = []
for chunk in chunks:
tasks.append(analyzer.assign_edges_to_chunk(chunk.text, candidate_list, note_type))
results_per_chunk = await asyncio.gather(*tasks)
# C. Injection & Fallback Tracking
assigned_edges_global = set()
for i, confirmed_edges in enumerate(results_per_chunk):
@ -381,18 +407,13 @@ async def _run_smart_edge_allocation(chunks: List[Chunk], full_text: str, note_i
chunk.suggested_edges = confirmed_edges
assigned_edges_global.update(confirmed_edges)
# Injection: Wir hängen die bestätigten Edges unsichtbar (fürs Embedding) oder sichtbar an
# Hier als "Pseudo-Code" im Text, damit sie embedded werden.
if confirmed_edges:
# Format: [[rel:kind|target]]
injection_str = "\n" + " ".join([f"[[rel:{e.split(':')[0]}|{e.split(':')[1]}]]" for e in confirmed_edges if ':' in e])
chunk.text += injection_str
chunk.window += injection_str
# D. Fallback: Kanten, die NIRGENDS zugewiesen wurden, werden JEDEM Chunk angehängt (Sicherheit)
unassigned = set(candidate_list) - assigned_edges_global
if unassigned:
logger.info(f"Chunker: {len(unassigned)} unassigned edges in {note_id}. Distributing to all chunks.")
fallback_str = "\n" + " ".join([f"[[rel:{e.split(':')[0]}|{e.split(':')[1]}]]" for e in unassigned if ':' in e])
for chunk in chunks:
chunk.text += fallback_str

View File

@ -1,10 +1,10 @@
"""
FILE: app/services/semantic_analyzer.py
DESCRIPTION: KI-gestützte Kanten-Validierung. Nutzt LLM (Background-Priority), um Kanten präzise einem Chunk zuzuordnen.
VERSION: 2.0.0
VERSION: 2.1.0 (Fix: Strict Edge String Validation against LLM Hallucinations)
STATUS: Active
DEPENDENCIES: app.services.llm_service, json, logging
LAST_ANALYSIS: 2025-12-15
LAST_ANALYSIS: 2025-12-16
"""
import json
@ -21,6 +21,34 @@ class SemanticAnalyzer:
def __init__(self):
self.llm = LLMService()
def _is_valid_edge_string(self, edge_str: str) -> bool:
"""
Prüft, ob ein String eine valide Kante im Format 'kind:target' ist.
Verhindert, dass LLM-Geschwätz ("Here is the list: ...") als Kante durchrutscht.
"""
if not isinstance(edge_str, str) or ":" not in edge_str:
return False
parts = edge_str.split(":", 1)
kind = parts[0].strip()
target = parts[1].strip()
# Regel 1: Ein 'kind' (Beziehungstyp) darf keine Leerzeichen enthalten.
# Erlaubt: "derived_from", "related_to"
# Verboten: "derived end of instruction", "Here is the list"
if " " in kind:
return False
# Regel 2: Plausible Länge für den Typ
if len(kind) > 40 or len(kind) < 2:
return False
# Regel 3: Target darf nicht leer sein
if not target:
return False
return True
async def assign_edges_to_chunk(self, chunk_text: str, all_edges: List[str], note_type: str) -> List[str]:
"""
Sendet einen Chunk und eine Liste potenzieller Kanten an das LLM.
@ -59,14 +87,13 @@ class SemanticAnalyzer:
)
try:
# 4. LLM Call mit Traffic Control (NEU: priority="background")
# Wir nutzen die "Slow Lane", damit der User im Chat nicht warten muss.
# 4. LLM Call mit Traffic Control
response_json = await self.llm.generate_raw_response(
prompt=final_prompt,
force_json=True,
max_retries=5,
base_delay=5.0,
priority="background" # <--- WICHTIG: Drosselung aktivieren
priority="background"
)
# LOG: Raw Response Preview
@ -91,30 +118,38 @@ class SemanticAnalyzer:
valid_edges = []
# 6. Robuste Validierung (List vs Dict)
# Wir sammeln erst alle Strings ein
raw_candidates = []
if isinstance(data, list):
# Standardfall: ["kind:target", ...]
valid_edges = [str(e) for e in data if isinstance(e, str) and ":" in e]
raw_candidates = data
elif isinstance(data, dict):
# Abweichende Formate behandeln
logger.info(f" [SemanticAnalyzer] LLM lieferte Dict statt Liste. Versuche Reparatur. Keys: {list(data.keys())}")
for key, val in data.items():
# Fall A: {"edges": ["kind:target"]}
if key.lower() in ["edges", "results", "kanten", "matches"] and isinstance(val, list):
valid_edges.extend([str(e) for e in val if isinstance(e, str) and ":" in e])
raw_candidates.extend(val)
# Fall B: {"kind": "target"}
# Fall B: {"kind": "target"} (Beziehung als Key)
elif isinstance(val, str):
valid_edges.append(f"{key}:{val}")
raw_candidates.append(f"{key}:{val}")
# Fall C: {"kind": ["target1", "target2"]}
elif isinstance(val, list):
for target in val:
if isinstance(target, str):
valid_edges.append(f"{key}:{target}")
raw_candidates.append(f"{key}:{target}")
# Safety: Filtere nur Kanten, die halbwegs valide aussehen
# 7. Strict Validation Loop
for e in raw_candidates:
e_str = str(e)
if self._is_valid_edge_string(e_str):
valid_edges.append(e_str)
else:
logger.debug(f" [SemanticAnalyzer] Invalid edge format rejected: '{e_str}'")
# Safety: Filtere nur Kanten, die halbwegs valide aussehen (Doppelcheck)
final_result = [e for e in valid_edges if ":" in e]
# LOG: Ergebnis

View File

@ -1,4 +1,4 @@
version: 2.4.0 # Optimized for Async Intelligence & Hybrid Router
version: 2.6.0 # Final WP-15 Config: Smart Edges & Strict/Soft Chunking
# ==============================================================================
# 1. CHUNKING PROFILES
@ -7,7 +7,6 @@ version: 2.4.0 # Optimized for Async Intelligence & Hybrid Router
chunking_profiles:
# A. SHORT & FAST
# Für Glossar, Tasks, Risiken. Kleine Schnipsel.
sliding_short:
strategy: sliding_window
enable_smart_edge_allocation: false
@ -16,7 +15,6 @@ chunking_profiles:
overlap: [30, 50]
# B. STANDARD & FAST
# Der "Traktor": Robust für Quellen, Journal, Daily Logs.
sliding_standard:
strategy: sliding_window
enable_smart_edge_allocation: false
@ -24,10 +22,8 @@ chunking_profiles:
max: 650
overlap: [50, 100]
# C. SMART FLOW (Performance-Safe Mode)
# Für Konzepte, Projekte, Erfahrungen.
# HINWEIS: 'enable_smart_edge_allocation' ist vorerst FALSE, um Ollama
# bei der Generierung nicht zu überlasten. Später wieder aktivieren.
# C. SMART FLOW (Text-Fluss)
# Nutzt Sliding Window, aber mit LLM-Kanten-Analyse.
sliding_smart_edges:
strategy: sliding_window
enable_smart_edge_allocation: true
@ -35,12 +31,38 @@ chunking_profiles:
max: 600
overlap: [50, 80]
# D. SMART STRUCTURE
# Für Profile, Werte, Prinzipien. Trennt hart an Überschriften (H2).
# D. SMART STRUCTURE (Soft Split)
# Trennt bevorzugt an H2, fasst aber kleine Abschnitte zusammen ("Soft Mode").
structured_smart_edges:
strategy: by_heading
enable_smart_edge_allocation: true
split_level: 2
strict_heading_split: false
max: 600
target: 400
overlap: [50, 80]
# E. SMART STRUCTURE STRICT (H2 Hard Split)
# Trennt ZWINGEND an jeder H2.
# Verhindert, dass "Vater" und "Partner" (Profile) oder Werte verschmelzen.
structured_smart_edges_strict:
strategy: by_heading
enable_smart_edge_allocation: true
split_level: 2
strict_heading_split: true # Hard Mode
max: 600
target: 400
overlap: [50, 80]
# F. SMART STRUCTURE DEEP (H3 Hard Split + Merge-Check)
# Spezialfall für "Leitbild Prinzipien":
# - Trennt H1, H2, H3 hart.
# - Aber: Merged "leere" H2 (Tier 2) mit der folgenden H3 (MP1).
structured_smart_edges_strict_L3:
strategy: by_heading
enable_smart_edge_allocation: true
split_level: 3
strict_heading_split: true
max: 600
target: 400
overlap: [50, 80]
@ -59,24 +81,13 @@ defaults:
types:
# --- KERNTYPEN (Hoch priorisiert & Smart) ---
# --- KERNTYPEN ---
experience:
chunking_profile: sliding_smart_edges
retriever_weight: 0.90
edge_defaults: ["derived_from", "references"]
# Hybrid Classifier: Wenn diese Worte fallen, ist es eine Experience
detection_keywords:
- "passiert"
- "erlebt"
- "gefühl"
- "situation"
- "stolz"
- "geärgert"
- "reaktion"
- "moment"
- "konflikt"
# Ghostwriter Schema: Sprechende Anweisungen für besseren Textfluss
detection_keywords: ["passiert", "erlebt", "gefühl", "situation", "reaktion"]
schema:
- "Situation (Was ist passiert?)"
- "Meine Reaktion (Was habe ich getan?)"
@ -87,48 +98,37 @@ types:
chunking_profile: sliding_smart_edges
retriever_weight: 0.97
edge_defaults: ["references", "depends_on"]
detection_keywords:
- "projekt"
- "vorhaben"
- "ziel ist"
- "meilenstein"
- "planen"
- "starten"
- "mission"
detection_keywords: ["projekt", "vorhaben", "ziel ist", "planen", "starten"]
schema:
- "Mission & Zielsetzung"
- "Aktueller Status & Blockaden"
- "Nächste konkrete Schritte"
- "Stakeholder & Ressourcen"
decision:
chunking_profile: structured_smart_edges
retriever_weight: 1.00 # MAX: Entscheidungen sind Gesetz
# Strict, damit jede Entscheidung atomar bleibt
chunking_profile: structured_smart_edges_strict
retriever_weight: 1.00
edge_defaults: ["caused_by", "references"]
detection_keywords:
- "entschieden"
- "wahl"
- "optionen"
- "alternativen"
- "beschluss"
- "adr"
detection_keywords: ["entschieden", "wahl", "optionen", "alternativen", "adr"]
schema:
- "Kontext & Problemstellung"
- "Betrachtete Optionen (Alternativen)"
- "Betrachtete Optionen"
- "Die Entscheidung"
- "Begründung (Warum diese Wahl?)"
- "Begründung"
# --- PERSÖNLICHKEIT & IDENTITÄT ---
value:
chunking_profile: structured_smart_edges
# Strict, damit Werte nicht verschwimmen
chunking_profile: structured_smart_edges_strict
retriever_weight: 1.00
edge_defaults: ["related_to"]
detection_keywords: ["wert", "wichtig ist", "moral", "ethik"]
schema: ["Definition", "Warum mir das wichtig ist", "Leitsätze für den Alltag"]
schema: ["Definition", "Warum mir das wichtig ist", "Leitsätze"]
principle:
chunking_profile: structured_smart_edges
# L3 Strict für P3/P3a und Tier2/MP1 Logik
chunking_profile: structured_smart_edges_strict_L3
retriever_weight: 0.95
edge_defaults: ["derived_from", "references"]
detection_keywords: ["prinzip", "regel", "grundsatz", "leitlinie"]
@ -138,11 +138,11 @@ types:
chunking_profile: sliding_short
retriever_weight: 0.90
edge_defaults: ["related_to"]
detection_keywords: ["glaube", "überzeugung", "denke dass", "meinung"]
schema: ["Der Glaubenssatz", "Ursprung & Reflexion"]
profile:
chunking_profile: structured_smart_edges
# Strict: Jede Rolle (H2) muss ein eigener Chunk sein
chunking_profile: structured_smart_edges_strict
retriever_weight: 0.70
edge_defaults: ["references", "related_to"]
schema: ["Rolle / Identität", "Fakten & Daten", "Historie"]
@ -159,8 +159,8 @@ types:
chunking_profile: sliding_short
retriever_weight: 0.85
edge_defaults: ["related_to", "blocks"]
detection_keywords: ["risiko", "gefahr", "bedrohung", "problem", "angst"]
schema: ["Beschreibung des Risikos", "Mögliche Auswirkungen", "Gegenmaßnahmen"]
detection_keywords: ["risiko", "gefahr", "bedrohung"]
schema: ["Beschreibung des Risikos", "Auswirkungen", "Gegenmaßnahmen"]
# --- BASIS & WISSEN ---
@ -168,10 +168,7 @@ types:
chunking_profile: sliding_smart_edges
retriever_weight: 0.60
edge_defaults: ["references", "related_to"]
schema:
- "Definition"
- "Kontext & Hintergrund"
- "Verwandte Konzepte"
schema: ["Definition", "Kontext", "Verwandte Konzepte"]
task:
chunking_profile: sliding_short
@ -183,19 +180,36 @@ types:
chunking_profile: sliding_standard
retriever_weight: 0.80
edge_defaults: ["references", "related_to"]
schema: ["Log-Eintrag", "Gedanken & Erkenntnisse"]
schema: ["Log-Eintrag", "Gedanken"]
source:
chunking_profile: sliding_standard
retriever_weight: 0.50
edge_defaults: []
schema:
- "Metadaten (Autor, URL, Datum)"
- "Kernaussage / Zusammenfassung"
- "Zitate & Notizen"
schema: ["Metadaten", "Zusammenfassung", "Zitate"]
glossary:
chunking_profile: sliding_short
retriever_weight: 0.40
edge_defaults: ["related_to"]
schema: ["Begriff", "Definition"]
schema: ["Begriff", "Definition"]
person:
chunking_profile: sliding_standard
retriever_weight: 0.50
edge_defaults: ["related_to"]
schema: ["Rolle", "Beziehung", "Kontext"]
event:
chunking_profile: sliding_standard
retriever_weight: 0.60
edge_defaults: ["related_to"]
schema: ["Datum & Ort", "Teilnehmer", "Ergebnisse"]
# --- FALLBACK ---
default:
chunking_profile: sliding_standard
retriever_weight: 1.00
edge_defaults: ["references"]
schema: ["Inhalt"]