One function
The RAG service is a single function: build_rag_context(query, …) -> str | None. It was designed for Claude-Pippa's chat route first — no thought given to variants. Each variant route adds a one-liner, rag_context = await build_rag_context(prompt_text, …), passing the conversation, soul, and viewer it is answering for. The function doesn't know which brain will consume it.
Where its answer goes has changed. Retrieved context used to be appended to the system prompt; since 2026-08-14 it rides the current user message as per-turn context, sent with the turn and never saved (lesson 8 has the reason). The function didn't change for that move. Its callers did, which is exactly what a narrow contract buys.
Cross-cutting without cross-contamination
RAG is shared because the underlying operation (semantic search over ChromaDB) is genuinely the same regardless of brain. The service is shared; the routes are still independent. What the service does own is scoping, because scoping is the same for every brain: vault chunks are locked to the active soul (plus the shared vault every soul inherits), past messages are shared across souls except conversations another soul has marked screen-safe, and a non-admin viewer only ever retrieves from their own conversations.
Graceful degradation
Several things make the function return None on purpose: an empty query, a low-signal one (a greeting or a name-call, which only overmatch), or a search that fails because the configured embedding provider is unreachable. On office that provider is oMLX on the server Mac (lesson 2). The route proceeds without retrieved context. Better degraded chat than a hard error.
Graceful degradation has a line, though. Carrying on without retrieval means answering from the context at hand, never pretending to remember what retrieval didn't find. And the skip is quiet today: a debug-level log line, nothing on screen. That is fine for a supplement, and it is exactly why retrieval must never become the only path to something an answer depends on.
The contract is narrow, so swapping providers doesn't shake the callers: a query in, a context string with provenance or None out, and no question about which brain will read it. Let the service decide prompt layout or a brain's token budget too, and cross-cutting turns into centralization.
The test for a shared service isn't that several callers use it. It is whether they share the same input, the same responsibility, and the same failure behavior. Those three match, so retrieval is shared, while prompt assembly stays with each route.
Retrieved context is evidence, not an answer, so provenance is half its value. Every chunk carries a tag saying whose memory it is (soul × user), and the whole block arrives wrapped as raw historical context: use it for facts, dates, and decisions, never as a voice to imitate.