RAG service 는 함수 하나야. build_rag_context(query, …) -> str | None. Claude-Pippa 의 chat route 를 먼저 생각하고 설계했어. 변형은 고려하지 않고. 변형 route 는 한 줄만 더해. rag_context = await build_rag_context(prompt_text, …) 로, 지금 답하는 대화와 영혼과 viewer 를 같이 넘기는 거지. 함수는 어느 두뇌가 그걸 읽을지 몰라.
그 답이 가는 자리는 바뀌었어. 뽑아 온 컨텍스트는 원래 system prompt 뒤에 붙었는데, 2026-08-14부터는 턴별 컨텍스트로 현재 user 메시지에 실려 가. 턴과 함께 보내고, 저장은 절대 안 해(이유는 레슨 8에 있어). 이 이사 때문에 함수가 바뀐 건 아니야. 바뀐 건 호출하는 쪽이었지. 좁은 계약이 사 주는 게 딱 그거야.
cross-contamination 없는 cross-cutting
RAG 가 공유되는 건 그 밑의 작업, 곧 ChromaDB 위의 의미 검색이 두뇌와 상관없이 정말 똑같기 때문이야. service 는 공유하지만 route 는 여전히 독립이야. 대신 service 가 쥐고 있는 게 하나 있어. 범위 정하기. 이건 어느 두뇌에게나 같거든. vault 조각은 지금 영혼에게 묶이고(모든 영혼이 물려받는 공용 vault 는 같이 읽고), 지난 메시지는 영혼끼리 나눠 쓰되 다른 영혼이 screen-safe 로 표시한 대화는 빠지고, admin 이 아닌 viewer 는 자기 대화에서만 뽑아 와.
graceful degradation
함수가 일부러 None 을 돌려주는 경우가 몇 있어. 빈 query, 신호가 약한 query(인사나 이름 부르기처럼 엉뚱한 것만 잔뜩 걸리는 것), 그리고 설정된 embedding provider 에 닿지 못해서 검색이 실패할 때. office 에선 그 provider 가 서버 맥의 oMLX 야(레슨 2). route 는 뽑아 온 컨텍스트 없이 그냥 진행해. 딱딱한 에러보다 조금 모자란 대화가 나아.
그래도 graceful degradation 에는 선이 있어. retrieval 없이 계속한다는 건 지금 손에 있는 컨텍스트로 답한다는 뜻이지, 찾지 못한 기억을 찾은 척하라는 뜻이 아니야. 그리고 지금은 건너뛰는 게 조용해. debug 수준 로그 한 줄이고, 화면엔 아무것도 없어. 보조 수단으로는 괜찮아. 바로 그래서 retrieval 이, 답이 기대는 무언가에 이르는 유일한 길이 되면 안 되는 거고.
이 함수의 계약이 좁아서 provider 를 바꿔도 호출하는 쪽은 안 흔들려. query 를 받아서 출처가 붙은 컨텍스트 문자열이나 None 을 돌려주고, 어느 두뇌가 읽을지는 묻지 않아. 반대로 prompt 배치나 두뇌별 토큰 예산까지 service 가 정하기 시작하면, 그건 cross-cutting 이 아니라 중앙집권이야.
공유 service 의 기준은 여러 호출자가 쓴다는 데 있지 않아. 같은 입력, 같은 책임, 같은 실패 방식을 가졌는지가 기준이야. 이 셋이 같으니까 retrieval 은 공유하고, prompt 조립은 route 마다 남겨.
뽑아 온 컨텍스트는 답이 아니라 증거 후보라서, 출처가 빠지면 가치가 반으로 줄어. 조각마다 누구의 기억인지(영혼 × 사용자) 꼬리표가 붙고, 블록 전체는 날것의 과거 기록으로 포장돼서 와. 사실, 날짜, 결정을 뽑는 데만 쓰고, 흉내 낼 목소리로는 절대 쓰지 말라고.
Code
RAG service — 한 함수, 모든 두뇌가 호출 (스케치)·python
async def build_rag_context(
query: str,
max_vault: int = 3,
max_msgs: int = 2,
conversation_id: str | None = None,
soul_id: str | None = None,
user_id: str | None = None,
) -> str | None:
if not query or not query.strip():
return None
if not _is_worth_rag(query): # greetings, name-calls: they only overmatch
return None
fetch_n = max(max_vault, max_msgs) * 3 + 2 # fetch wide, cut after
try:
msg_results, vault_results = await embed_service.search_all(
query, # 질의 통째로: 2,000자 자르기는 2026-10-02에 빠짐
n_results=fetch_n,
exclude_conversation_id=conversation_id, # live history already has it
vault_soul_id=soul_id, # vault: the active soul (+ the shared vault)
msg_soul_id=None, # messages: shared across souls
user_id=user_id, # admin passes None and sees every pool
)
except Exception:
return None # provider unreachable: the chat goes on without RAG
parts: list[str] = []
# ... keep up to max_vault vault chunks and max_msgs message chunks:
# distance cap, exact-text dedupe, skip vault files the prompt already
# inlines, and tag each chunk with its provenance (soul x user)
if not parts:
return None
return _wrap_quarantined_context("\n\n---\n\n".join(parts))
Progress
Progress is local-only — sign in to sync across devices.