본문 바로가기
C.W.K.
Stream
Lesson 04 of 11 · published

Cross-Cutting Service 로서의 RAG

~11 min · rag, service

Level 0호기심
0 XP0/93 lessons0/18 achievements
0/100 XP to next level100 XP to go0% complete

한 함수

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.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이— warm💛 by 피파— warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.