Code › tail-villain

개인정보 없이 RAG를 관찰하기

쿼리와 원문을 남기지 않고 RAG 호출, 선별, 지연 시간을 운영 지표로 관찰한 과정

tail-villain의 RAG는 이미 운영 경로에 들어가 있었다. Study에서는 사용자의 Goal과 Background를 찾아 설명과 연습 문제에 반영했고, 텍스트 Interview에서도 같은 개인화 근거를 인터뷰 질문에 넣을 수 있었다. 검색이 되는지, 소유권이 섞이지 않는지, 답변에 개인화 신호가 나타나는지는 앞선 작업에서 확인했다.

그런데 운영에 올리고 나니 다른 종류의 빈칸이 보였다. 실제 요청에서 RAG가 몇 번 호출되는지, 후보는 몇 개나 잡히는지, 임계값을 통과해 프롬프트에 들어간 근거는 얼마나 되는지, 느린 요청이 Study에서 많은지 Interview에서 많은지 알 수 없었다. 기능은 존재했지만, 운영자는 검색층을 거의 보지 못하고 있었다.

더 곤란한 점은 이 영역이 사용자의 민감한 텍스트와 바로 붙어 있다는 것이다. 사용자가 입력한 목표, 이력, 회사명, 프로젝트 경험, 면접 답변은 개인화의 재료이지만, 그 텍스트를 그대로 로그에 남기면 관측성이 아니라 데이터 복제가 된다. 특히 RAG 로그는 검색 실패를 분석하려고 오래 보관하거나 관리자 화면에서 훑어볼 가능성이 있어서 더 조심해야 했다.

그래서 이번에는 RAG의 원문을 더 잘 보는 방법이 아니라, 원문을 보지 않고도 운영 상태를 볼 수 있는 구조를 만들었다.


필요한 것은 텍스트가 아니라 구조였다

처음 확인하고 싶었던 질문은 단순했다. 요청이 들어왔을 때 검색이 실제로 호출됐는가. 호출됐다면 pgvector 검색이 성공했는가. 벡터 후보는 몇 개였고, 그중 몇 개가 minScore를 넘어서 프롬프트 문맥으로 선별됐는가. 가장 높은 후보 점수는 어느 정도였고, 선별된 근거의 source kind는 Goal인지 Background인지, 처리 시간은 얼마나 걸렸는가.

이 질문들에는 사용자 문장이 필요하지 않았다. 쿼리 텍스트가 없어도 호출 여부와 지연 시간은 알 수 있고, 이력서 문장이 없어도 후보 수와 선별 수는 남길 수 있다. 검색된 조각의 본문을 저장하지 않아도 top candidate score와 admitted score를 보면 점수 분포가 어느 정도인지 파악할 수 있다. 문서 제목이나 chunk id를 남기지 않아도 source kind만 있으면 Goal 쪽 문맥이 주로 들어가는지 Background 쪽 문맥이 주로 들어가는지 볼 수 있다.

그래서 RAG 검색은 별도의 LLM usage log 항목으로 남기되, operation은 rag_retrieval, provider는 pgvector로 구분했다. LLM 호출은 아니지만 기존 사용량 로그가 이미 시간, 상태, 작업 종류를 모으는 장소였기 때문에 운영 이벤트를 함께 모으기에 맞았다. embedding 사용량은 별도 operation으로 계속 남겨 두었다. 이렇게 해야 embedding provider 비용과 pgvector 검색 지연 시간을 섞지 않고 볼 수 있다.

로그에는 호출 여부, skip reason, surface, 성공 또는 에러 상태, 전체 duration, 설정된 topK와 minScore, candidate count, admitted count, top candidate score, admitted rank, source kind, admitted score를 넣었다. Study인지 Interview인지도 surface로 남겼다. 같은 검색이라도 Study 답변과 Interview 질문은 제품에서 다른 역할을 하기 때문이다.

반대로 일부러 남기지 않은 값도 정했다. learner query text, source title, source id, document id, chunk id, retrieved snippet text는 제외했다. 내부 상관관계를 보기 위한 roadmap, topic, session 식별자는 남겼지만, 운영자가 관리자 화면에서 읽을 수 있는 개인 원문은 남기지 않았다.

이 경계가 중요했다. 관측성은 장애를 줄이기 위한 장치이지, 사용자 입력을 다시 저장하는 두 번째 저장소가 아니기 때문이다.


성공과 실패도 답변과 분리했다

RAG가 제품 흐름 안에서 보이지 않으면, 나중에는 답변만 보고 검색을 추측하게 된다. 답변이 개인화되지 않았을 때 검색이 호출되지 않은 건지, 후보는 있었지만 점수가 낮아서 선별되지 않은 건지, 근거는 들어갔지만 모델이 쓰지 않은 건지 구분하기 어렵다. 반대로 답변이 좋아 보여도 검색이 느렸거나 매번 후보를 너무 많이 훑고 있었을 수 있다.

그래서 검색 telemetry는 생성 결과와 독립적으로 남겼다. Study 답변이 만족스러운지, Interview 질문이 좋은 follow-up인지와 별개로, 검색층은 자신의 이벤트를 남긴다. 호출이 스킵됐다면 스킵 사유를 남기고, pgvector에서 에러가 났다면 에러 상태를 남긴다. 후보가 있었지만 아무 것도 선별되지 않았다면 candidate count와 admitted count의 차이가 그대로 보인다.

특히 admission count는 RAG 품질을 운영 관점에서 보기 좋은 숫자였다. 후보가 열 개 나왔다는 사실만으로는 프롬프트에 실제 근거가 들어갔는지 알 수 없다. minScore를 통과한 근거가 0개라면 사용자는 일반 답변을 받게 되고, 너무 많은 근거가 계속 들어가면 비용과 잡음이 늘어난다. candidate count와 admitted count를 같이 남겨야 검색 범위와 임계값을 조정할 수 있다.

score도 한 가지가 아니었다. top candidate score는 검색 공간에서 가장 가까운 문맥이 어느 정도로 잡히는지 보여 주고, admitted score는 실제로 프롬프트에 들어간 근거의 점수다. 두 값을 나눠 보면 후보는 있는데 선별이 막히는지, 선별은 되지만 점수대가 낮은지, 또는 특정 surface에서만 분포가 달라지는지 확인할 수 있다.

duration은 사용자가 체감하는 문제와 바로 연결된다. Study에서는 설명을 기다리는 시간이 길어지고, Interview에서는 질문이 늦게 돌아오면 압박감보다 끊김이 먼저 느껴진다. RAG가 전체 응답 시간을 얼마나 밀어 올리는지 보려면 생성 모델 latency와 검색 latency를 분리해야 했다. 이번 로그는 pgvector 검색 자체의 duration을 남겨서 그 경계를 만들었다.

로그 저장은 best effort로 처리했다. telemetry 저장에 실패했다고 사용자의 Study 답변이나 Interview 턴을 실패시킬 수는 없다. 검색과 생성이 성공했다면 사용자는 결과를 받아야 하고, 로그 실패는 경고로 남겨 운영자가 따로 확인하면 된다.


관리자 화면은 원문 대신 분포를 보여 줬다

개별 이벤트만 쌓이면 다시 로그를 뒤져야 한다. 그래서 Admin Eval 쪽에 RAG metrics API를 추가했다. 최근 검색 이벤트를 모아 hit rate, called count, skipped count, error count, P50과 P95 duration, average top score, admitted source-kind counts, surface별 breakdown을 보여 주도록 했다.

기본 조회 기간은 7일로 두고, 요청 범위는 1일부터 90일 사이로 제한했다. 처리량도 최신 5,000개 row로 묶었다. 관리자 화면이 운영 도구가 되려면 무한히 오래된 로그를 매번 훑어서는 안 되고, RAG 상태를 빠르게 판단할 수 있는 최근 분포를 보여 주는 편이 낫다.

여기서도 텍스트는 필요하지 않았다. Study에서 hit rate가 낮고 Interview에서는 정상이라면 surface별 연결 방식이나 주입 조건을 볼 수 있다. 둘 다 called count는 높은데 admitted count가 낮다면 minScore, source scope, 질문 embedding 쪽을 의심할 수 있다. P95 duration이 튄다면 pgvector query나 후보 범위, 데이터 크기를 보면 된다. Goal보다 Background source kind가 과하게 적다면 사용자의 배경 정보가 충분히 검색되지 않는지 확인해야 한다.

물론 이 지표만으로 좋은 답변을 보장할 수는 없다. 이전 작업에서도 봤듯이 검색된 근거가 답변에 등장한다고 해서 Study가 올바른 다음 학습 행동을 주는 것은 아니다. 이번 dashboard는 답변 품질 평가를 대체하려고 만든 것이 아니라, 답변 품질을 보기 전에 검색층이 정상적으로 움직이는지 확인하기 위한 바닥이다.

그 바닥이 없으면 모든 문제가 프롬프트 문제처럼 보인다. 실제로는 RAG가 호출되지 않았거나, 후보는 있었지만 admission이 0이었거나, 특정 surface에서만 source kind가 비어 있을 수 있다. 반대로 검색 지표가 정상이라면 그다음에는 생성 정책과 수업 진행 규칙을 봐야 한다.

이제 검색층과 생성층의 문제를 나눠서 확인할 수 있었다.


관측성에도 개인정보 최소화가 필요했다

RAG를 운영하면서 가장 쉽게 빠질 수 있는 유혹은 실패 사례를 그대로 저장하는 것이다. 어떤 질문에서 검색이 실패했는지, 어떤 snippet이 들어갔는지 보면 디버깅은 편해진다. 하지만 tail-villain의 RAG 원천은 사용자가 직접 입력한 목표와 배경이고, Interview 쪽으로 갈수록 개인 경험과 약점이 더 많이 섞인다.

그래서 로그만 읽고 사용자의 이야기를 복원할 수 없도록 기준을 잡았다. candidate count, admitted count, score, duration, source kind만으로도 첫 운영 dashboard는 만들 수 있었다. 부족한 것은 개별 텍스트가 아니라 충분한 이벤트 수와 표면별 분포였다.

이 결정은 나중의 디버깅을 조금 불편하게 만든다. 어떤 문장이 왜 검색되지 않았는지 바로 읽을 수 없고, 특정 사용자의 맥락을 재현하려면 별도의 권한과 더 좁은 절차가 필요하다. 그래도 운영 로그의 기본값은 불편한 쪽이 맞다고 봤다. 개인정보가 들어갈 수 있는 텍스트를 남기지 않는 설계는 한번 정하면 계속 지켜지지만, 이미 쌓인 민감 로그를 나중에 안전하게 만드는 일은 훨씬 어렵다.

이번 작업으로 RAG가 더 똑똑해진 것은 아니다. 검색 모델을 바꾸지도 않았고, 답변 생성 규칙을 크게 고치지도 않았다. 대신 운영에서 RAG가 실제로 호출되는지, 무엇을 통과시키는지, 얼마나 걸리는지, Study와 Interview에서 다르게 움직이는지를 볼 수 있게 됐다.

tail-villain에서 개인화는 사용자 텍스트를 많이 들여다보는 일이 아니라, 필요한 순간에 필요한 구조만 안전하게 가져오는 일이어야 한다. 7월 16일의 RAG 작업은 그 기준을 운영 화면까지 끌고 온 작업이었다.