Motivation / problem
Commodity RAG is stateless about decisions. It indexes chunks and re-derives an answer from raw similarity on every query. Three consequences hurt at enterprise scale:- It cannot represent “we evaluated X and rejected it” — there is nowhere to store a rejection, so the LLM cheerfully re-proposes it next quarter.
- It cannot follow the relationships between knowledge (a decision supersedes another; a runbook documents a module) — every hit is an island.
- It forgets the moment the context window closes.
Theory & background
Two ideas drive the design:- Anti-repetition memory. A team’s most valuable knowledge is often what it
stopped doing. Encoding
rejected-approachas a first-class canonical type, and surfacing it at query time, turns “we already tried that” from tribal memory into a retrieval signal. - Graph-augmented retrieval. Pure vector search optimises for semantic similarity, not relevance through relationship. A decision’s superseding decision, or the runbook that operationalises it, may not be textually similar to the query yet is essential context. Walking edges from the seed hits recovers that context.
Design
At every chat turn, after hybrid retrieval and reranking, two components run:GraphExpanderwalks one hop ofkb_edgesfrom the canonical seed documents and folds the neighbours intoSearchResult.expanded. Ordering is driven by edgeweight.RejectedApproachInjectorvector-correlates the query againstrejected-approachcanonical docs and returns up toKB_REJECTED_INJECTION_MAX_DOCSaboveKB_REJECTED_MIN_SIMILARITY.- The prompt (
resources/views/prompts/kb_rag.blade.php) renders typed blocks: a ⚠ REJECTED APPROACHES block, a 📎 RELATED CONTEXT block, and the primary ## Context — so the model is explicitly told what was dismissed.
Data model / contract
kb_nodes— 9 node types;rejected-approachis one of them.kb_edges— 10 edge types (supersedes,invalidated_by,decision_for,documented_by,affects, …), each with aweight(decimal 8,4) driving expansion order and aprovenance.- Config knobs:
KB_GRAPH_EXPANSION_ENABLED(defaulttrue)KB_REJECTED_INJECTION_ENABLED(defaulttrue)KB_REJECTED_INJECTION_MAX_DOCS,KB_REJECTED_MIN_SIMILARITY
Decision rationale (ADR-style)
- Why surface rejected approaches in the prompt rather than just down-rank
them? Down-ranking hides them; the goal is the opposite — the model must
know an option was dismissed to avoid re-proposing it. The ⚠ marker is a
feature, not a hack. Typical token cost is <300 tokens/turn; tune
KB_REJECTED_MIN_SIMILARITYbefore disabling. - Why 1-hop graph expansion, not N-hop? One hop captures the
directly-related context (superseding decision, owning module) without
exploding the prompt or pulling in weakly-related noise. Deeper multi-hop
navigation is a deliberate, separate agentic primitive (Auto-Wiki
WikiNavigator), not the default chat path. - Why degrade to a no-op? A tenant with zero canonical docs gets identical behaviour to plain hybrid RAG — existing consumers see no change until they canonicalize. Both components return empty in that case.
Worked example
- Promote a decision and the approach it rejected:
- Ask near the rejected option:
- The grounded answer carries a ⚠ REJECTED APPROACHES block citing
in-process cache, and the chat-side Related panel walks the graph to the supersedingdec-cache-v2.
Gotchas & operations
- Graph expansion + rejected injection are config-gated — never assume they are “always on” in new code.
- They operate only over canonical docs; non-canonical corpora behave like plain RAG.
- Edge
weightis load-bearing for expansion ordering — set it deliberately in frontmatter, not arbitrarily.
Architecture overview
The kb_nodes / kb_edges schema and project-scoped composite FKs.
Anti-hallucination firewall
Why human-vouched knowledge always outranks machine output.