본 튜토리얼은 Samuel Molling에 의해 작성되었습니다.
고객 지원 에이전트는 겉으로 보기에는 간단합니다. 누군가 질문을 하면 답변을 받는 채팅 상자일 뿐입니다. 하지만 그 인터페이스 뒤편의 작업은 결코 단순하지 않습니다. 답변은 제품 문서에 있을 수도 있고, 몇 달 전에 다른 에이전트가 해결했던 지원 티켓에 있을 수도 있으며, 고객 자신의 계정 데이터에 있을 수도 있습니다. 때로는 에이전트가 자체적으로 조치할 수 없는 곳에 답이 존재하기도 합니다._
이 실험의 중심에는 MongoDB Atlas를 기반으로 구축된 검색 증강 생성(Retrieval-Augmented Generation, RAG) 파이프라인이 있습니다. 질문은 임베딩되어 Atlas Vector Search를 통해 문서 청크와 검색되고, Atlas Search는 전체 아티클에서 어휘적 일치 항목을 검색합니다. 이 순위가 매겨진 목록들은 Reciprocal Rank Fusion으로 결합되며, 가장 강력한 후보들은 크로스 인코더(cross-encoder)를 사용하여 재순위화되고, 최고의 구절들이 답변의 컨텍스트로 LLM에 전달됩니다._
이 파이프라인은 입력이 자체적으로 완결된 검색 쿼리일 때 잘 작동합니다. 하지만 고객 지원은 다릅니다._
고객은 완벽하게 자체적으로 완결된 질문을 하는 경우는 거의 없습니다. 그들은 무언가를 질문하고, 그것을 명확히 하며, 이전 답변을 참조하고, 자신의 계정에 대해 질문하며, 때로는 에이전트가 전혀 처리해서는 안 되는 것을 요청합니다.
다음 대화를 고려해 보세요:
- 귀사 제품은 SAML SSO를 지원하나요?
- Pro 플랜에서도 사용 가능한가요?
- 그리고 비용은 얼마나 되나요?
마지막 질문은 검색 쿼리로는 거의 쓸모가 없습니다. 여기에는 제품, 기능 또는 플랜 이름이 없습니다. 그 의미는 그 앞의 대화 덕분에 존재합니다.
게다가 검색(retrieval)은 문제의 한 부분일 뿐입니다. 지원 세션 전반에 걸쳐, 하나의 답변은 문서에서 올 수 있고, 다른 하나는 이전에 해결된 티켓에서 올 수 있으며, 또 다른 것은 운영 고객 데이터에서 올 수 있습니다. 때로는 정확한 다음 단계가 아예 답변이 아닐 수도 있습니다. 그것은 사람일 수 있습니다.
이 글은 검색(retrieval) 파이프라인을 중심으로 지원 에이전트가 갖추어야 할 계층들, 즉 쿼리 재작성(query rewriting), 대화 메모리(conversation memory), 여러 코퍼스(multiple corpora), 고객 권한(customer entitlements), 운영 도구(operational tools), 신뢰도(confidence), 그리고 에스컬레이션(escalation)에 대해 다룹니다.
Example of the console answering as the Enterprise customer. The inspection panel shows the condensed question, the classification, the confidence band, and the five passages that survived fusion and reranking.
엔터프라이즈 고객의 입장에서 답변하는 콘솔 예시입니다. 검사 패널에는 요약된 질문, 분류(classification), 신뢰도 범위(confidence band), 그리고 융합 및 재순위 지정(fusion and reranking)을 거쳐 살아남은 다섯 개의 구절이 표시됩니다.
쿼리가 대화가 될 때 무엇이 바뀌는가
작업 단위가 하나의 구절과 하나의 답변으로 쿼리를 처리하는 것에서 전체 대화를 해결하는 것으로 이동합니다. 이는 UX(사용자 경험)의 변화처럼 들리지만, 실제로는 데이터 모델링의 변화이자 보안의 변화이며 제어 흐름(control flow)의 변화입니다.
실제 네 번째 차례를 가정해 봅시다: “그럼 그건 얼마인가요?” 다섯 단어로 이루어져 있습니다. 제품도 없고, 플랜도 없고, 임베딩할 만한 동사도 없습니다. 이것을 그대로 벡터 인덱스에 보내면 아무 의미 없는 문장으로부터 계산된 벡터 때문에 노이즈만 검색합니다. 실패는 잘못된 검색(bad retrieval)으로 나타나고, 엔지니어는 numCandidates를 조정하는 데 몰두하게 됩니다.
이제 세션 전반에 걸쳐 고객이 실제로 필요로 하는 것을 생각해 봅시다. 첫 번째 차례의 답변은 문서에 있습니다. 두 번째 차례의 답변은 8개월 전에 해결된 티켓에서 가져오는데, 이는 이미 사람이 이 정확한 문제를 해결하고 기록했기 때문입니다. 세 번째 차례의 답변은 운영 컬렉션(operational collection)에 있는 주문 ORD-1042의 배송 상태이며, 이것은 애초에 검색 문제가 아닙니다. 네 번째 차례의 답변은 이 고객이 취소하기를 원한다는 것이며, 어떤 코퍼스에서도 에이전트가 이를 처리할 권한을 부여하는 구절이 없습니다.
네 번의 차례, 네 가지 다른 데이터 소스, 그중 하나는 사람입니다. 검색 파이프라인은 그 중 한 가지만 처리합니다.
이 구현에서의 전체 차례별 흐름(per-turn flow)은 다음과 같습니다:
One turn of the agent, end to end. Condensation runs first and can skip retrieval entirely. Escalation is evaluated before generation, not after it.
에이전트의 한 턴(turn)으로, 엔드투엔드(end-to-end). 컨덴세이션(Condensation)이 먼저 실행되어 검색(retrieval) 단계를 완전히 건너뛸 수 있습니다. 에스컬레이션(Escalation)은 생성(generation) 후에가 아니라 그 이전에 평가됩니다.
스테이지 2와 스테이지 3은 트랜스크립트(transcript)를 담고 있는 동일한 Atlas 클러스터에서 실행됩니다. 이는 들리는 것보다 더 중요하며, 나중에 다시 언급하겠습니다.
컨덴세이션: 재작성하고 답변하지 않기
턴이 검색될 수 있으려면 먼저 자체적으로 완결된 형태가 되어야 합니다. JSON 모드에서의 LLM 호출 한 번으로 턴을 재작성(rewrites)함과 동시에 분류(classifies)합니다. 왜냐하면 둘 다 동일한 컨텍스트를 필요로 하며, 두 번째 왕복(second round trip)은 새로운 정보 없이 턴당 지연 시간만 두 배로 늘리기 때문입니다.
{
standaloneQuestion: "SAML SSO 비용은 얼마인가요?",
intent: "billing_question",
...
}
프롬프트에서 흥미로운 부분은 지침(instruction) 자체가 아닙니다. 그것은 금지 사항입니다.
제약이 없는 재작성기(rewriter)는 사실을 꾸며내는 효율적인 기계입니다. SSO에 관한 스레드 뒤에 "그리고 그건 얼마인가요?"라는 질문만 주어져도, 이 재작성기는 다음과 같이 기꺼이 생성합니다:
50석 규모의 팀이 연간 청구되는 경우 Pro 플랜에서 SAML SSO 비용은 얼마인가요?
고객이 말하지 않은 세 가지 제약 조건입니다. 플랜, 좌석 수, 청구 기간. 각각 하나씩 인덱스 내부의 후보군을 걸러냅니다. 원래 질문에 답할 수 있었던 구절(passage)은 이제 도달 불가능해지며, 로그 어디에도 쿼리가 꾸며졌다는 내용은 없습니다. 여러분이 보는 것은 그럴듯해 보이는 질문이지만 잘못된 결과를 반환한 경우입니다.
과도한 컨덴세이션은 조용히 실패하며 검색 버그처럼 보입니다. 이것이 시스템 프롬프트가 작업 내용보다 '무엇을 하지 말아야 하는지'에 더 많은 단어를 할애하는 이유입니다:
- 질문에 답변하지 마십시오. 당신은 지원 담당자가 아닙니다.
- 고객이 실제로 말하지 않은 제약 조건, 숫자, 제품 이름, 플랜 이름, 버전,
수량 또는 한정사를 추가하지 마십시오.
...
_needsRetrieval_은 호출의 다른 절반이며 전체 단계를 포괄합니다. 고객이 "고마워요, 해결되었네요"라고 입력하면 분류기가 false를 반환하고 해당 턴(turn)은 임베딩, 두 번의 벡터 검색, 리랭크(rerank), 그리고 프롬프트 내 다섯 개의 패시지(passage) 단계를 건너뜁니다. 매 턴마다 전체 파이프라인을 실행하는 것은 순진한 에이전트(naive agent)의 대부분의 턴당 비용을 차지하며, 실제 지원 스레드에서 의미 있는 비율의 턴은 확인(acknowledgments)입니다.
JSON 모드는 구문적으로 유효한 JSON을 보장합니다. 하지만 유효한 형태(shape)까지는 보장하지 않습니다. 모든 필드는 다운스트림에서 읽히기 전에 폐쇄 집합(closed set)으로 클램핑됩니다:
const intent = INTENTS.includes(obj.intent) ? obj.intent : "other";
흘러 들어온 인식되지 않은 의도(unrecognized intent)는 정책을 테스트하는 과정 없이 에스컬레이션 정책(escalation policy)을 우회할 수 있습니다. 클램핑은 방어적인 스타일이 아닙니다. 그것은 분류기가 환각(hallucination)을 일으켜 정책 우회로 변질되는 것을 막아주는 장치입니다.
세 가지 계층의 메모리
대화 상태는 이 시스템에서 명백한 데이터 모델이 한 번에 세 방향으로 잘못된 부분입니다.
명백한 모델은 메시지를 대화 문서(conversation document) 내 배열에 넣습니다. 이는 지역성(locality)을 보존하며, findOne 하나로 모든 것을 반환하고, 형태가 애플리케이션의 구조를 반영합니다. 하지만 다음과 같이 성능이 저하됩니다.
모든 추가(append)는 전체 문서를 다시 작성합니다. 40번째 턴은 한 줄을 추가하기 위해 39개 턴 분량의 텍스트를 재작성하므로, 쓰기 증폭(write amplification)이 대화 길이에 선형적으로 증가합니다. 작업 집합(working set) 크기는 중앙값(median)이 아니라 가장 긴 대화를 기준으로 측정됩니다. 왜냐하면 해당 대화에 대한 모든 읽기(read)가 모든 턴을 캐시로 끌어당기고, 위젯을 닫지 않는 한 고객 한 명이 전체 컬렉션의 메모리 프로파일을 부풀리기 때문입니다. 게다가 16MB라는 하드 벽이 존재합니다. 이는 매우 먼 거리입니다. 충분히 지속적인 사용자는 이 벽에 도달할 것이고, 그들이 도달했을 때 실패는 평범한 메시지에 대한 처리되지 않은 쓰기 오류가 됩니다.
메시지는 자체 컬렉션에 존재합니다:
messages: {
_id, conversationId, seq, role, content, meta: {}, createdAt
}
_{ conversationId: 1, seq: -1 }_를 사용하면 "마지막 K개 턴(last K turns)"을 읽는 것은 인덱스 스캔이며, 추가되는 내용은 길이가 어떻든 상수 시간(constant-time)으로 유지됩니다.
이 전사본(transcript) 위에는 대화 문서에 있는 두 개의 레이어가 더 있습니다:
conversations: {
_id, customerId, status,
summary, // rolling prose digest (롤링 산문 요약)
...
모델로 전송되는 컨텍스트 창(context window)은 summary + last K verbatim turns + facts입니다. 이 예산(budget)은 고정되어 있습니다. 전체 전사본을 재생하는 대신 비용과 지연 시간(latency)이 대화 길이에 따라 증가하게 되는데, 이는 완전히 역효과입니다. 왜냐하면 가장 긴 대화가 이미 문제가 생기고 있는 경우이기 때문입니다.
요약(summary)은 매 6개 턴마다 재생성되며, 매 턴마다 재생성되지 않습니다. 모든 턴에서 요약하는 것은 정보가 느리게 변하는 내용을 재진술하기 위해 LLM 호출을 두 배로 늘립니다. 왜냐하면 120번째 턴의 요약본은 121번째 턴의 요약본과 거의 동일하기 때문입니다. verbatim window는 요약이 아직 흡수하지 못한 모든 것을 다룹니다.
facts 배열이 존재하는 이유는 요약기(summarizer)가 구조적으로 의역하는 기계이기 때문입니다. 모델에게 대화를 압축하도록 요청하면, 그 모델은 "ORD-1042"를 15번째 턴 근처에서 "고객의 최근 주문(the customer's recent order)"과 같이 부드럽게 만듭니다. 요약기 프롬프트는 모든 식별자(identifier), 오류 코드(error code), 제품 이름, 명시된 제약 조건(stated constraint)을 verbatim으로 보존하도록 요구하며, 저는 여전히 고객 상호작용에 그것을 걸지 않을 것입니다. 구조화된 레이어(structured layer)는 주문 ID가 살아남는 곳인데, 그곳에는 텍스트를 더 읽기 좋게 만들려고 하는 것이 아무것도 없기 때문입니다.
세션은 만료시키지만 고객은 만료시키지 않는 TTL
하나의 인덱스는 별도의 설명이 필요합니다:
{ key: { expiresAt: 1 }, name: "conversations_ttl_anonymous", expireAfterSeconds: 0 }
TTL(Time To Live) 인덱스는 인덱싱된 필드를 가진 문서만 만료시킵니다. expiresAt이 없는 문서는 영원히 무시됩니다. 그리고 expiresAt은 익명의 방문자가 세션을 시작할 때 정확히 한 곳에 기록됩니다.
따라서 이 규칙이 나타내는 것은, 익명 세션은 캐시(cache)이고 로그인한 고객의 대화는 그렇지 않다는 것입니다.
모든 대화에 걸쳐 균일한 TTL(Time-To-Live)은 깔끔해 보이지만, 사실상 느린 속도로 데이터를 손실시키는 방식입니다. 고객의 지원 이력은 운영 데이터이며, 여기에 보유 정책, 규정 준수 기록, 그리고 '3월에 무엇을 말했었지?'라는 질문이 첨부되어 있습니다. 이를 롤링(rolling) 30일 창으로 삭제하면 오류나 백업 없이 그 기록을 조용히 파괴합니다. 누군가 3월 대화를 요청할 때 비로소 알게 됩니다.
두 개의 코퍼스: 문서와 해결된 티켓
문서는 제품이 설계된 대로를 설명합니다. 반면, 티켓은 사용자가 경험한 대로를 설명합니다. 이 둘은 다른 코퍼스이며, 후자는 선택 사항이 아닙니다. 왜냐하면 그 사이의 표현 격차에서 실제 지원 활동이 일어나기 때문입니다.
오류는 다음과 같이 문서화됩니다:
AUTH_302는 Identity Provider가 제시한 SAML 어설션에 워크스페이스에 구성된 Entity ID와 일치하지 않는 Audience URI를 포함할 때 반환됩니다.
고객은 다음과 같이 보고합니다:
_
Okta로 로그인한 후 로그인 페이지로 되돌아갑니다.
_
이 두 텍스트는 거의 공통 어휘가 없습니다. 해결된 티켓은 문제를 해결한 에이전트가 작성하며, 증상으로 서술하고 해결책으로 마무리되는 형태로 중간에 위치합니다. 그리고 이것이야말로 고객의 말과 실제로 일치하는 부분입니다.
따라서 파이프라인은 두 개의 코퍼스에 걸쳐 세 개의 랭커(ranker)를 실행하고 이를 융합합니다:
weights: {
kbChunks: 1.0,
kbArticles: 0.7,
...
융합은 각 랭커별 가중치를 가진 Reciprocal Rank Fusion (RRF)을 사용합니다. RRF는 BM25가 무한하고 코사인 유사도가 [0, 1] 범위에 존재하기 때문에 원시 점수(raw score)가 아닌 순위(rank)로 융합하며, 이들을 서로 정규화한다는 것은 아무도 방어할 수 없는 변환을 발명하는 것과 같습니다.
score(doc) = Σ over rankers weight_r × 1 / (k + rank_r(doc)) // k = 60
MongoDB 8.1에서는 $rankFusion이 네이티브 스테이지로 제공됩니다. 이 실험실에서는 산술 연산이 보이도록 RRF를 수동으로 구현했지만, 8.1 이상 버전의 프로덕션 환경에서는 해당 스테이지를 사용하는 것이 좋습니다.
티켓 랭커는 문서에는 없는 두 가지 조정 사항을 포함합니다.
최신성 감쇠(Recency decay). 18개월 전 해결된 문제는 더 이상 존재하지 않는 콘솔 화면을 설명할 수 있습니다. 티켓의 기여도는 180일마다 절반으로 줄어듭니다: