Spring AI RAG, 관측 클라우드로 전구간 관측하는 법
글은 RAG를 관련 자료를 먼저 찾아 대모델이 답하게 하는 방식으로 설명하며, 문제는 완전한 불능보다 간헐적 지연이라고 말한다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
RAG 애플리케이션을 만들 때 가장 골치 아픈 문제는 대개 '아예 못 쓴다'가 아니라 '가끔 좀 느리다'이다.
RAG는 우선 '먼저 관련 자료를 찾은 뒤, 대규모 모델이 그 자료를 가지고 답하게 한다'고 이해하면 된다. Embedding은 텍스트 질문을 숫자 벡터로 바꾸는 역할을 하고, Milvus는 이 벡터들을 저장하고 가장 유사한 내용을 찾아낸다.
사용자가 질문을 보냈는데, 페이지가 몇 초 동안 돌다가 그제야 출력을 시작한다. 애플리케이션 로그는 이번 요청이 총 3초 남짓 걸렸다고 알려주지만, 그 3초가 어디에 쓰였는지는 말해주지 않는다.
질문을 벡터로 바꾸는 Embedding 모델이 느린 것일 수도, Milvus의 유사도 검색이 느린 것일 수도, 아니면 대규모 모델이 첫 글자를 생성하는 게 느린 것일 수도 있다. 더 쉽게 간과되는 경우도 있다. Milvus 자체는 문제가 없고, 애플리케이션과 Milvus 사이의 네트워크가 느린 것이다.
총 소요 시간만 하나 있으면 이 경우들은 거의 똑같아 보인다. 문제를 찾는 일은 추측에만 의존할 수밖에 없다. 서비스를 재시작하고, 모델을 바꾸고, 스레드 풀을 조정하고, 심지어 벡터 DB에 먼저 책임을 떠넘기기도 한다.
이번에는 빈 프로젝트로 데모를 만들지 않고, 내 Spring AI Alibaba RAG 애플리케이션을 직접 개조했다. 프로젝트는 Spring Boot 3.2.0, Spring AI 1.0.0, Spring AI Alibaba 1.0.0.2를 사용하고, Embedding 모델은 text-embedding-v4, 생성 모델은 qwen-plus, 벡터 DB는 서버에 배포된 Milvus이다.
나는 Windows 로컬 머신에 DataKit을 설치하고, 애플리케이션의 링크와 지표를 관측 클라우드(观测云)로 보내는 동시에, 로컬에서 네트워크를 넘어 원격 Milvus의 Prometheus 지표를 수집했다. 연동한 뒤에는 '애플리케이션에서 Milvus'로 이어지는 이 네트워크 구간에 일부러 지연을 추가하고, 장애와 복구 두 그룹의 대조 실험을 돌렸다.
최종 결과는 명확했다. 이번 느려짐은 벡터 검색의 클라이언트 링크에서 나타났지만, Milvus 서버 측 처리 시간은 함께 올라가지 않았다. 즉, 문제는 네트워크 경로에 있고, 대규모 모델에 있지도 않으며, 'Milvus가 느려졌다'고 간단히 적을 수도 없다.
먼저 분명히 하자: 내가 보고 싶은 것은 수많은 곡선이 아니다
모니터링을 연동하기 전에, 나는 이번 실천에 하나의 검수 기준을 정했다. 관측 페이지는 '데이터가 올라갔다'는 것만 증명하는 게 아니라, 구체적인 질문에 답할 수 있어야 한다.
하나의 RAG 요청에 대해 나는 최소한 다음 호출 체인을 볼 수 있어야 한다.
사용자 요청 └─ ChatClient ├─ Advisor / 기억 처리 ├─ EmbeddingModel ├─ Milvus VectorStore query └─ ChatModel
Trace는 하나의 요청이 각 단계를 거치는 완전한 호출 기록이며, 그 안의 각 시간 측정 구간을 Span이라 한다. Trace는 '이번 한 번의 요청에서 시간이 어디로 갔는가'에 답하고, 애플리케이션 지표는 '같은 종류의 작업이 최근 전반적으로 느려졌는가'에 답하며, Milvus 지표는 '벡터 DB 내부에서 동시에 압력이 나타나고 있는가'에 답한다. 이 세 층이 합쳐져야 모델과 벡터 DB, 네트워크를 구분할 기회가 생긴다.
나는 한 가지 프라이버시 요구 사항도 추가했다. 소요 시간을 보기 위해 사용자 질문과 모델 답변, 회수된 문서를 함께 업로드해서는 안 된다는 것이다. 성능을 찾아내는 데는 작업 이름, 소요 시간, 상태, 필요한 라벨이면 충분하고, 본문 내용은 관측 플랫폼에 들어갈 필요가 없다.
나의 실제 링크는 어떻게 생겼는가
애플리케이션은 Windows 로컬 머신에서 실행되고, Milvus는 원격 서버에 있으며, 관측 클라우드는 SaaS이다. DataKit은 데이터 수집과 전달을 담당하는 프로그램으로, 나는 이를 Milvus 서버가 아니라 애플리케이션이 있는 로컬 머신에 설치했다.
이렇게 둔 데는 두 가지 이유가 있다.
첫째, 애플리케이션이 OTLP Trace를 로컬 주소로 보내므로 수신 포트를 공용망에 노출할 필요가 없다. 둘째, DataKit은 로컬 애플리케이션의 Prometheus 엔드포인트와 원격 Milvus의 지표 엔드포인트를 동시에 긁어와 통일적으로 업로드할 수 있다.
실제 링크는 다음과 같다.
Spring AI Alibaba RAG ├─ OTLP Trace ───────────────┐ └─ /actuator/prometheus ──┐ │ ↓ ↓ Windows DataKit ──→ 관측 클라우드 ↑ 원격 Milvus /metrics ───────┘
이것은 흔한 질문 하나에도 답한다. Milvus가 서버에 있으면 로컬 DataKit으로 모니터링할 수 있는가? 가능하다. 전제는 로컬 머신이 Milvus의 지표 엔드포인트에 접근할 수 있고, 서버 방화벽이 필요한 출처에만 개방되어 있어야 한다는 것이다. 나의 실천이 바로 이런 배포 방식이다.
DataKit 2.13.0은 설치 후 Windows 서비스로 실행되며, 시작 유형은 Automatic이다. 관측 클라우드 인프라 페이지에서 이미 이 호스트를 볼 수 있었으니, 가장 기본적인 데이터 경로가 정상임을 뜻한다.
애플리케이션 측에는 네 가지만 더했다
프로젝트는 원래부터 Spring AI의 ChatClient, Advisor, EmbeddingModel, MilvusVectorStore를 사용하고 있었다. 이 점이 매우 중요한데, Spring AI가 이미 이 컴포넌트들에 Micrometer Observation을 제공하고 있기 때문이다. 연동할 때 각 메서드 밖에 수동으로 타이머를 작성할 필요 없이, 먼저 기존의 관측 기능을 켜기만 하면 된다.
첫 번째: 의존성 보강
나는 pom.xml에 Actuator, Prometheus 지표 레지스트리, Micrometer의 OpenTelemetry 브리지, OTLP 익스포터를 추가했다.
< dependency > < groupId > org.springframework.boot </ groupId > < artifactId > spring-boot-starter-actuator </ artifactId > </ dependency > < dependency > < groupId > io.micrometer </ groupId > < artifactId > micrometer-registry-prometheus </ artifactId > </ dependency > < dependency > < groupId > io.micrometer </ groupId > < artifactId > micrometer-tracing-bridge-otel </ artifactId > </ dependency > < dependency > < groupId > io.opentelemetry </ groupId > < artifactId > opentelemetry-exporter-otlp </ artifactId > </ dependency >
Actuator는 헬스 체크와 Prometheus 지표, 즉 지속적으로 긁어와 통계 내고 비교할 수 있는 수치를 노출한다. Tracing Bridge는 Micrometer의 Observation을 링크로 변환한다. OTLP Exporter는 다시 범용 전송 프로토콜을 통해 링크를 DataKit으로 보낸다.
두 번째: OTLP와 Prometheus 구성
내 애플리케이션 포트는 9166이고, 관리 엔드포인트는 별도로 로컬 9167에 두었다. 핵심 구성은 다음과 같다.
management: server: address: 127.0 .0 .1 port: 9167 endpoints: web: exposure: include: health,prometheus tracing: sampling: probability: 1.0 otlp: tracing: endpoint: http://127.0.0.1:9529/otel/v1/traces timeout: 10s metrics: tags: application: ${spring.application.name} environment: practice
여기서 100% 샘플링은 이번 실천과 소규모 트래픽 검증에만 적합하다. 프로덕션 환경에서 요청량이 많으면 비용과 문제 해결 필요에 따라 조정해야 한다. 그렇지 않으면 링크량이 빠르게 늘어난다.
관리 포트를 127.0.0.1에 바인딩한 것도 의도적이다. Prometheus 엔드포인트는 애플리케이션 실행 정보를 포함하므로, 수집 편의를 위해 공용망에 그대로 노출해서는 안 된다. 로컬 DataKit이 여기에 접근할 수 있다면 접근 범위를 넓힐 필요가 없다.
세 번째: DataKit이 두 종류의 지표를 긁어오게 하기
애플리케이션 측에서 긁어올 대상은 다음과 같다.
http://127.0.0.1:9167/actuator/prometheus
원격 Milvus에서 긁어오는 대상은 서버에 있는 Prometheus 지표 주소이다. 나의 수집 구성은 모든 지표를 한꺼번에 다 받아들이지 않고, 먼저 이번 문제 해결과 직접 관련된 검색, Proxy, QueryNode, 리소스 상태 지표를 남겼다.
Windows 설치판 DataKit의 Prom 구성은 C:\Program Files\datakit\conf.d\prom 에 있다. 애플리케이션 측 구성은 우선 이 한 부를 쓰면 된다.
[[inputs.prom]] urls = [ "http://127.0.0.1:9167/actuator/prometheus" ] interval = "15s" timeout = "10s" source = "clxzs-spring-ai" metric_types = [ "counter" , "gauge" , "histogram" , "summary" ] metric_name_filter = [ "^gen_ai_.*$" , "^db_vector_.*$" , "^http_server_requests_seconds.*$" , "^jvm_memory_used_bytes$" , "^process_cpu_usage$" ] measurement_name = "clxzs_spring_ai" keep_exist_metric_name = true election = false
원격 Milvus는 별도로 구성을 만들고, 주소를 자신의 서버 내부망 주소 또는 통제된 접근 주소로 바꾼다.
[[inputs.prom]] urls = [ "http://<MILVUS_HOST>:9091/metrics" ] interval = "15s" timeout = "10s" source = "milvus-remote" metric_types = [ "counter" , "gauge" , "histogram" ] metric_name_filter = [ "^milvus_proxy_req_latency.*$" , "^milvus_proxy_sq_latency.*$" , "^milvus_querynode_sq_req_latency.*$" , "^milvus_querynode_sq_req_count$" ] measurement_name = "milvus" keep_exist_metric_name = true election = false
저장한 뒤, 관리자 PowerShell로 Restart-Service datakit 을 실행한다. 먼저 로컬에서 두 지표 주소를 열어 텍스트가 반환되는지 확인하고, 다음으로 DataKit 로그에 아직 긁어오기 오류가 있는지 보고, 마지막으로 관측 클라우드에서 source 또는 measurement로 조회한다. 세 단계를 모두 통과해야 수집이 실제로 연결된 것으로 본다.
이것은 관측 비용에서很容易 밟는 함정이다. Milvus 지표는 수가 적지 않은데, 고카디널리티 라벨과 모든 시계열을 그대로 풀어놓으면 페이지는 풍성해질 수 있지만 청구서도 풍성해질 수 있다. 먼저 문제를 중심으로 허용 목록을 만들고, 이후 새로운 시나리오를 만나면 확장하는 것이 '일단 다 받고 보자'보다 안정적이다.
연동 후, 관측 클라우드에서 이미 Spring AI의 gen_ai_client_operation_* , db_vector_client_operation_* 등의 지표를 조회할 수 있었다.
원격 Milvus의 검색 및 컴포넌트 소요 시간 지표도 같은 워크스페이스에 들어왔으며, 뒤에서 애플리케이션 측 소요 시간과 교차 판단하는 데 사용할 것이다.
네 번째: 민감한 본문은 기본적으로 닫아두기
나는 다음 세 항목의 비활성 상태를 명확히 유지했다.
spring: ai: chat: client: observations: log-prompt: false log-completion: false vectorstore: observations: log-query-response: false
동시에 증강 프롬프트를 로그에 기록하던 디버그 코드를 제거했다. 전체 실험에서 요청 본문은 메모리 안에서만 사용되었고, 증거를 남길 때는 응답 바이트 수와 SHA-256만 기록했으며, 사용자 질문과 모델 답변, 회수된 문서를 증거 디렉터리에 기록하지 않았다.
이것은 “안전해 보이게” 하기 위한 것이 아니다. 프롬프트에 업무 자료, 사용자 정보 또는 내부 지식이 포함되는 순간, 본문 수집을 켜면 데이터 경계가 달라진다. 성능 문제 진단에는 보통 이런 내용이 필요하지 않으므로 기본적으로 꺼 두는 편이 더 적절하다.
첫 번째 함정: Span이 있다고 해서 완전한 Trace가 있는 것은 아니다
처음 요청을 실행했을 때, 관측 클라우드에는 이미 데이터가 나타났지만 링크는 완전하지 않았다.
HTTP 요청, ChatClient, 모델 생성은 하나의 Trace에 있었지만, Embedding과 Milvus query는 마치 두 개의 고립된 링크처럼 보였다. 목록만 보면 모든 단계가 있는 것 같지만, 요청 하나를 클릭해 들어가면 그것들을 부모-자식 관계로 연결할 수 없었다.
원인은 리액티브 컨텍스트 전파에 있었다. 이 프로젝트는 스트리밍 생성을 사용하는데, 코드가 Reactor 비동기 경계를 넘은 뒤 관측 컨텍스트가 자동으로 전달되지 않았다. Span은 생성되었지만 부모 Trace 정보가 사라진 것이다.
수정은 한 항목만 추가했다:
spring: reactor: context-propagation: auto
다시 시작한 뒤, 같은 요청에서 12개의 Span이 나타났다: HTTP 진입점, spring_ai_chat_client, 메시지 메모리, 벡터 검색 Advisor, Embedding, Milvus query, qwen-plus 스트리밍 생성이 모두 같은 하나의 Trace 아래에 걸려 있었다.
이 단계는 “Trace를 볼 수 있다”보다 더 중요하다. 링크가 연결되지 않으면 이후의 소요 시간 비교에서 잘못된 표본을 잡기 쉽다. 모니터링 플랫폼은 애플리케이션 안의 컨텍스트 전파를 자동으로 대신 고쳐 주지 않으며, 페이지에 데이터가 있다고 해서 연동이 완료된 것은 아니다.
하나의 RAG Trace는 어떻게 읽어야 하는가
처음 링크 상세를 열면 십여 개의 Span 이름에 겁먹기 쉽다. 내가 읽는 방식은 위에서 아래로 한 줄씩 연구하는 것이 아니라, 먼저 사용자 대기 시간에 가장 큰 영향을 주는 세 가지 주간(主干)을 찾는 것이다.
먼저 ChatModel을 본다. 그것은 보통 비교적 긴 구간을 차지하지만, 스트리밍 생성에는 특별한 점이 하나 있다: 총 생성 시간이 길다고 해서 사용자가 계속 빈 페이지를 보고 있었다는 뜻은 아니다. 운영 환경에서 진단할 때는 첫 글자 시간과 전체 생성 시간을 나누어 보는 것이 가장 좋다. 첫 글자가 빠르고 이후 계속 출력된다면 사용자 체감이 반드시 나쁘다고 할 수 없고, 첫 글자가 좀처럼 나오지 않는다면 총 소요 시간이 같더라도 체감은 분명히 훨씬 더 나쁠 것이다.
다음으로 Embedding을 본다. 그것은 사용자 질문을 벡터로 변환하는 역할을 하며, Milvus에 진입하기 전 단계다. 이 구간이 갑자기 높아지면 먼저 모델 서비스의 네트워크, 속도 제한, 타임아웃, 배치 전략을 확인한다. 이때 Milvus 인덱스를 조정하는 것은 보통 도움이 되지 않는다. 요청이 아직 실제로 벡터 검색에 들어가지 않았기 때문이다.
마지막으로 VectorStore query를 본다. 이것은 애플리케이션 측에서 보이는 벡터 검색 총 소요 시간이며, 그 안에는 연결 수립, 네트워크 왕복, 서버 측 대기 및 실제 쿼리가 포함될 수 있다. 이 Span이 길어지면 범위를 “애플리케이션이 벡터 저장소를 호출하는 이 구간”으로 좁힐 수 있을 뿐, 벡터 저장소 내부가 느려졌다고 바로 단정할 수는 없다. 계속 Milvus Proxy, QueryNode 지표와 대조해야 한다.
Advisor, 메시지 메모리, 프롬프트 조립도 볼 가치가 있지만, 우선순위는 비중에 달려 있다. 그것들이 단지 몇 밀리초만 차지한다면 먼저 최적화를 서두르지 말아야 하고, 커스텀 Advisor 안에 데이터베이스 쿼리, 파일 읽기 또는 원격 인터페이스가 있다면 더 세분화된 비즈니스 Span을 계속 보강해야 한다. Trace의 역할은 모든 줄을 초록색으로 만드는 것이 아니라, 다음 단계에서 시간을 어디에 써야 할지 알려 주는 것이다.
또한 부모-자식 관계와 시간축을 함께 봐야 한다. 두 Span이 모두 500ms를 쓴 것처럼 보여도, 병렬로 실행된다면 총 소요 시간에 대한 기여는 여전히 500ms에 가까울 수 있고, 직렬로 실행될 때라야 1초로 누적될 수 있다. 목록에 있는 소요 시간을 전부 더하기만 하면, 인터페이스 총 소요 시간을 초과하는 잘못된 숫자를 얻기 쉽다.
여기까지 해서 애플리케이션 Trace, 애플리케이션 지표, 원격 Milvus 지표가 이미 로컬 DataKit을 통해 같은 워크스페이스로 모였다.
그다음, 나는 일부러 느린 요청을 한 번 만들었다
나는 Toxiproxy를 애플리케이션과 원격 Milvus 사이에 두고, 이 테스트 링크에만 지연을 추가했다: 업링크 350ms, 다운링크 350ms, 강도 100%. Embedding 모델과 ChatModel의 접근 경로는 변하지 않았고, Milvus 서비스 자체에도 CPU 제한이나 리소스 부하 테스트를 하지 않았다.
테스트 컨테이너의 시작 명령은 다음과 같으며, 포트는 로컬에만 바인딩된다:
docker run -d --name clxzs-milvus-toxiproxy ` -p 127.0.0.1:8474:8474 ` -p 127.0.0.1:19531:8666 ` ghcr.io/shopify/toxiproxy:2.12.0
그다음 Toxiproxy의 8474 관리 인터페이스를 통해 0.0.0.0:8666 → <MILVUS_HOST>:19530 프록시를 생성하고, 이어서 upstream과 downstream 두 개의 latency toxic을 각각 추가했으며, latency는 모두 350으로 설정했다. 애플리케이션 테스트 시에는 임시로 MILVUS_PORT를 19531로 변경했다.
이 일련의 작업은 격리된 테스트 환경에만 적합하다. 결과를 확인한 뒤에는 먼저 두 개의 toxic을 삭제하고, 이어서 docker rm -f clxzs-milvus-toxiproxy 를 실행한 다음, 애플리케이션 포트를 19530으로 복구하고 재시작한다. 상태 확인이 UP가 아니거나, 검색이 200을 반환하지 않거나, 복구 후 소요 시간이 다시 내려가지 않으면 실험이 끝난 것으로 볼 수 없다.
각 단계는 같은 인터페이스와 같은 질문을 사용하고, 고정된 횟수를 기록한 다음, 지연을 제거하고 복구 그룹을 실행한다. 따라서 뒤에서 벡터 검색 소요 시간이 증가한 것을 보면, 먼저 그것을 “Milvus로 가는 네트워크가 느려졌다”라고 불러야 하며, “Milvus 서버가 느려졌다”라고 바로 말해서는 안 된다.
스트리밍 모델의 변동이 결론을 방해하는 것을 줄이기 위해, 나는 동시에 두 그룹의 데이터를 본다: 완전한 질의응답 요청은 하나의 실제 RAG Trace를 관찰하는 데 사용하고, 독립 검색 엔드포인트는 연속 세 번 호출하여 벡터 검색의 중앙값을 비교하는 데 사용한다. 그리고 Micrometer 누적 값으로 VectorStore, Embedding, ChatModel의 단계별 평균 소요 시간을 계산한다.
장애 단계에서, 완전한 요청은 관측 클라우드에서 총 소요 시간 2.40초로 표시되었으며, 그중 Milvus query는 약 1.37초, ChatModel은 약 1.02초였다.
직접 연결로 복구한 뒤, 요청 하나의 총 소요 시간은 1.84초였고, Milvus query는 약 739밀리초로 돌아왔으며, ChatModel은 약 1.05초였다.
단일 Trace는 모델 응답, 네트워크 지터, 캐시의 영향을 받으므로, 나는 이 두 그림으로 직접 백분율을 계산하지 않았다. 더 안정적인 대조는 고정 횟수의 검색 요청과 단계 누적 지표에서 나온다:
관찰 항목 / 장애 단계 / 복구 단계 / 변화 /debug/search 세 번 중앙값 / 1595ms / 1097ms / 약 498ms 증가 VectorStore 평균 소요 시간 / 1395ms / 838ms / 약 557ms 증가 Embedding 평균 소요 시간 / 158ms / 244ms / 장애에 따라 상승하지 않음 ChatModel 소요 시간 / 1023ms / 1050ms / 기본적으로 안정적 Milvus Proxy 서버 측 평균 소요 시간 / 466ms / 507ms / 장애에 따라 상승하지 않음 Milvus QueryNode 표본 평균 소요 시간 / 233ms / 253ms / 장애에 따라 상승하지 않음
이 표가 이번 진단 전체에서 가장 가치 있는 증거다.
애플리케이션 측에서 보이는 VectorStore는 약 557ms 늘었지만, Embedding과 ChatModel은 함께 느려지지 않았다. 계속 Milvus 서버 측을 보면, Proxy와 QueryNode의 처리 시간도 상승하지 않았다. 클라이언트는 시간을 더 썼지만 서버 측은 같은 시간만큼 더 많은 일을 하지 않았으므로, 그 차이가 향한 가장 합리적인 곳은 애플리케이션과 Milvus 사이의 네트워크 경로다.
여기서 두 숫자가 다소 “정돈되지 않아” 보일 수 있다. 내가 주입한 것은 업링크와 다운링크 각각 350ms로, 이론적으로는 700ms인데, /debug/search 중앙값은 약 498ms만 증가했고 VectorStore 평균값은 약 557ms 증가했다. 실제 시스템에서는 연결 재사용, 샘플링 창, 원격 서비스의 자연스러운 변동이 모두 결과에 영향을 주므로, 관측값이 설정값과 밀리초 단위까지 정확히 같기를 기대할 수는 없다.
내가 더 관심을 두는 것은 방향이 일치하는지 여부다: 장애 단계에서 클라이언트 벡터 검색이 뚜렷하게 높아졌다가 복구 후 내려갔고, 이 경로와 무관한 Embedding과 ChatModel은 같은 방향으로 상승하지 않았으며, Milvus 내부 지표에도 대응하는 증가분이 나타나지 않았다.
이것이 완전한 질의응답과 독립 검색을 나누어 테스트해야 하는 이유이기도 하다. 대규모 모델 생성에는 무작위 변동이 있어서, 완전한 요청 두 번만 비교하면 한 번은 모델이 빠르고 한 번은 모델이 느려져, 오히려 네트워크 지연을 가릴 가능성이 크다. 독립 검색 엔드포인트는 모델 변수를 줄이고, 완전한 Trace는 실제 사용자 링크를 유지하므로, 둘은 각기 다른 질문에 답한다.
애플리케이션 총 소요 시간만 보면 나는 “RAG가 느려졌다”라고만 말할 수 있다. VectorStore Span만 보면 나는 “Milvus가 느려졌다”라고 말할 수도 있다. 애플리케이션 Trace와 Milvus 서버 측 지표를 함께 놓아야 범위를 네트워크로 좁힐 충분한 근거가 생긴다.
실험이 끝난 뒤, 나는 Toxiproxy의 toxic과 테스트 컨테이너를 삭제하고, 애플리케이션을 원격 Milvus 직접 연결로 되돌렸다. 상태 확인은 UP였고, 벡터 검색은 200을 반환했으며, 복구 그룹 지표도 다시 내려갔다.
세 계층의 데이터를 함께 놓으면, 추측을 한 바퀴 줄인다
먼저 결론부터 말하자면: 관측 클라우드가 나를 대신해 “네트워크 문제를 자동으로 진단”해 준 것은 아니다. 그것이 제공하는 것은 서로 다른 계층의 데이터를 함께 관찰할 수 있는 조건이며, 최종 판단은 여전히 실험 설계와 교차 검증에서 나온다.
가장 직접적인 도움은 하나의 Trace가 한 번의 RAG 요청을 분해해 준 것이다. 이전에는 인터페이스 총 소요 시간만 볼 수 있었지만, 이제는 특정 요청을 클릭해 열어 시간이 주로 벡터 검색에 쓰였는지 모델 생성에 쓰였는지 확인할 수 있다. 간헐적인 느린 요청에 대해서는 평균값만 보는 것보다 더 유용하다.
Spring AI 지표와 Milvus 지표는 다시 전체 관점을 보완해 준다. Trace는 “이번 한 번” 무슨 일이 일어났는지 알려 주고, Prometheus 지표는 “이런 종류의 작업”이 지속적으로 이상한지 알려 준다. 두 관점을 함께 놓아야 이번 네트워크와 서버 측 배제를 완성할 수 있었다.
DataKit은 데이터를 어떻게 한곳으로 모을지 해결해 준다: 애플리케이션은 로컬 OTLP 엔드포인트로만 보고하고, 관리 엔드포인트도 로컬만 수신한다. DataKit이 다시 원격 Milvus 지표에 접근한다. 이런 배포는 Windows 개발 환경과 소규모 실천에 비교적 편리하다.
하지만 그것도 연결만 하면 모든 것이 끝나는 것은 아니다.
링크가 완전한지는 애플리케이션이 컨텍스트를 올바르게 전파했는지에 달려 있고, 자동 Span의 커버 범위는 코드가 Spring AI의 표준 컴포넌트를 거치는지에 달려 있으며, 지표량과 비용은 수집 범위와 레이블 기수에 달려 있고, 실제로 문제를 찾을 수 있는지는 기준선과 대조가 있는지에 달려 있다.
이번에 페이지가 “근본 원인은 네트워크”라는 문장을 자동으로 띄워 주지는 않았다. 그것은 나로 하여금 한 번의 요청, 애플리케이션 지표, Milvus 지표를 더 빠르게 함께 놓을 수 있게 해 주었지만, 장애 변수는 여전히 사람이 통제해야 하고 비교 기준도 미리 정해야 한다. 이 두 단계가 빠지면 같은 그림도 서로 다른 결론으로 해석될 수 있다.
이번 경험에서 보면, 그것은 이미 여러 구간의 호출이 있으면서도 링크 플랫폼과 지표 플랫폼을 각각 유지하고 싶지 않은 팀에 비교적 적합하다. 특히 RAG처럼 모델 서비스, 벡터 저장소, 비즈니스 코드를 가로지르는 흐름에서는 하나의 요청이 각 단계로 드릴다운되고, 다시 같은 워크스페이스의 서비스 지표로 전환할 수 있어 진단 경로가 훨씬 짧아진다.
그에 따르는 대가는 현실적이다. DataKit 설정을 유지해야 하고, 지표 이름을 이해해야 하며, 샘플링과 데이터량을 처리해야 하고, 서비스에 안정적인 레이블을 보완해야 한다. 페이지는 어떤 레이블이 높은 기수를 초래할지 대신 결정해 주지 않고, 어떤 비즈니스 필드가 민감한지도 알지 못한다. 인터페이스가 하나뿐이고 서비스 간 호출이 거의 없는 작은 프로젝트에는 이런 투자가 반드시 이득이 되지 않을 수 있고, 이미 간헐적인 느린 요청이 나타났고 로그로 재현하기 어려운 RAG 애플리케이션에 이르러서야 수익이 더 뚜렷하다.
따라서 이번 Windows 로컬 애플리케이션과 원격 Milvus의 실천에서, 그것은 실제로 OTLP Trace, Spring AI 지표, Milvus 지표를 이어받았고, 내가 장애에서 복구까지의 계층 간 대조를 완성하도록 지원했다.
만약 프로젝트가 DashScope 네이티브 SDK, Milvus Java SDK를 직접 호출하거나, 전체 RAG 흐름을 커스텀 스레드와 비동기 작업 안에 캡슐화한다면, 일부 단계는 자동으로 나타나지 않을 수 있다. 이때는 모니터링 플랫폼을 바꾸면 자동으로 채워질 것이라고 생각할 것이 아니라, 비즈니스 경계에 커스텀 Observation 또는 OpenTelemetry Span을 보완해야 한다.
이 방법을 다른 프로젝트로 가져가기
당신도 RAG 애플리케이션에 관측을 추가하려 한다면, 더 수월한 출발점은 먼저 십여 장의 패널을 만드는 것이 아니라 하나의 최소 폐루프다.
먼저 안정적인 요청 하나를 골라 정상 상태의 완전한 Trace를 기록한다. Embedding, VectorStore, ChatModel이 모두 같은 링크에 있고 부모-자식 관계가 올바른지 확인한다. 이 결과가 기준선이 된다.
그다음 세 개의 데이터 진입점을 확인한다: 애플리케이션 OTLP Trace, 애플리케이션 Prometheus, Milvus Prometheus. 어느 하나라도 빠져 있으면 먼저 수집을 고치고, 장애 결론을 서두르지 않는다.
이어서 복구 가능한 작은 장애를 한 번 만든다. 테스트 환경의 네트워크 지연, 통제된 타임아웃 또는 속도 제한이어도 되지만, 한 번에 변수 하나만 바꾸고 복구 방법을 미리 명확히 적어 둔다. 운영 환경에서 직접 시도해서는 안 된다.
마지막으로 다음 순서에 따라 판단한다:
| 보이는 현상 | 우선 의심할 대상 | 다음 단계 | | --- | --- | --- | | ChatModel Span 상승, 벡터 검색 안정적 | 모델 서비스 또는 모델 네트워크 | 모델 요청 오류율, 첫 토큰 시간, 공급자 상태 확인 | | Embedding Span 상승, Milvus 안정적 | Embedding 서비스 | 양자화 요청, 속도 제한, 타임아웃, 배치 크기 확인 | | VectorStore 클라이언트와 Milvus 서버 측이 동시에 상승 | Milvus 내부 또는 리소스 압박 | Proxy, QueryNode, CPU, 메모리, 디스크 확인 | | VectorStore 클라이언트 상승, Milvus 서버 측 안정적 | 애플리케이션에서 Milvus까지의 네트워크 경로 | 크로스 IDC, 프록시, 커넥션 풀, DNS, 패킷 손실 확인 | | 총 소요 시간 상승, 그러나 각 핵심 Span 변화는 크지 않음 | 커버되지 않은 비즈니스 코드 또는 대기 | 비즈니스 Span 보완, 스레드 풀과 비동기 경계 확인 |
이 표는 보편적 진리는 아니지만, "벡터 검색이 느리다"는 말을 보자마자 곧바로 Milvus 파라미터를 조정하는 일을 피할 수 있게 해준다. 느린 시간이 클라이언트에서 발생했는지, 네트워크에서 발생했는지, 서버 측에서 발생했는지 먼저 판단한 다음에 무엇을 바꿀지 결정해야 한다.
프로젝트를 관측 클라우드에 연결할 때 실제로 걸리기 쉬운 지점
첫 번째는 DataKit의 두 OTLP 진입점을 뒤섞는 것이다. 4317은 보통 gRPC를 받고, 9529는 HTTP 인터페이스를 제공한다. 이 프로젝트는 Spring Boot의 HTTP 내보내기 방식을 사용하므로 주소를 http://127.0.0.1:9529/otel/v1/traces 로 적는다. 포트 하나만 그대로 옮겨 적고 프로토콜과 경로는 맞추지 않으면, DataKit 서비스는 정상으로 보이지만 관측 클라우드에서는 여전히 Trace를 받지 못한다.
두 번째는 DataKit이 어디에 설치되어 있는지를 무시하는 것이다. 이번에는 DataKit과 애플리케이션이 같은 Windows 컴퓨터에 있으므로 Actuator를 127.0.0.1:9167에 바인딩해도 여전히 수집할 수 있다. 만약 DataKit을 서버로 옮겨 설치하면, 이 주소는 서버 자신만을 가리키게 되어 개발 컴퓨터의 관리 엔드포인트를 잡을 수 없다. 수집기와 애플리케이션을 같은 장비에 두거나, 관리 엔드포인트에 통제된 접근 가능 주소를 설정해야 하며, 이번 주소를 그대로 복사해서는 안 된다.
세 번째는 Milvus의 비즈니스 포트와 지표 포트를 혼동하는 것이다. 애플리케이션은 19530으로 벡터 조회를 하고, DataKit이 수집하는 것은 9091/metrics 이다. 19530만 연결이 된다고 테스트해서는 Milvus 지표를 수집할 수 있다는 것을 의미하지 않는다. 원격 배포 시에는 9091이 DataKit이 있는 머신에서 접근 가능한지도 확인하고, 접근 출처를 제한해야 한다.
네 번째는 장애 복구에서 나타난다. Toxiproxy 컨테이너를 삭제한 뒤 애플리케이션이 여전히 19531을 가리키고 있다면, 다음 요청은 곧바로 연결 실패가 난다. 복구할 때는 MILVUS_PORT를 19530으로 되돌리고, 애플리케이션을 재시작한 다음, 건강 상태, 검색 응답, 벡터 검색 소요 시간을 차례로 확인해야 한다. 세 가지 결과가 모두 복구되어야 테스트 환경이 진짜로 마무리된 것이다.
다섯 번째는 모든 RAG 코드가 자동으로 Span을 생성한다고 생각하는 것이다. ChatClient, Advisor, EmbeddingModel, VectorStore가 Spring AI 표준 컴포넌트를 거칠 때는 자동 관측이 비교적 완전하다. 만약 코드가 DashScope나 Milvus 네이티브 SDK를 직접 호출하거나, 검색을 사용자 정의 비동기 스레드에 넣는다면, 빠진 단계는 여전히 Observation이나 사용자 정의 Span으로 보완해야 한다.
마지막은 "더 자세히 보기" 위해 본문 수집을 켜는 것이다. log-prompt, log-completion, log-query-response는 이번 소요 시간 파악에 필요하지 않으며, 꺼 둔 상태에서도 모델, 벡터 검색, 네트워크를 구분할 수 있다. 정말로 켜야 한다면, 먼저 사용자 질문, 모델 답변, 회수된 문서가 관측 플랫폼에 들어가도 되는지 확인해야 한다.
마지막 판단
이번 실습을 마친 뒤, 나는 "RAG 관측 가능성"에 대해 아주 소박한 기준을 갖게 되었다. 페이지에 Trace 몇 개와 곡선 몇 개가 나타나는 것으로 완료되는 것이 아니라, 한 번의 답변이 느려졌을 때 같은 요청을 따라 의심스러운 단계를 찾아내고, 또 다른 층의 데이터로 잘못된 판단을 배제할 수 있어야 한다는 것이다.
이번 실험에서 완전한 Trace는 먼저 느린 지점을 VectorStore로 가리켰다. 애플리케이션 지표는 그것이 단일 요청의 우연한 변동이 아님을 확인해 주었다. Milvus 서버 측 지표는 다시 내부 처리 지연을 배제했다. 세 층의 증거를 합쳐서야 "애플리케이션에서 Milvus까지의 네트워크 링크가 느려졌다"는 결론에 도달했다.
관측 클라우드는 여기서 작업대에 더 가깝다. Trace, 애플리케이션 지표, 원격 서비스 지표를 같은곳에 모아 준다. 작업대 자체가 판단을 대체하지는 않지만, 판단에 근거와 경로를 만들어 주고, 복구 후 결과가 정말로 내려갔는지도 볼 수 있게 해준다.
이 방법을 자기 프로젝트에 적용할 준비가 되었다면, 먼저 큰 화면을 만들려고 서두르지 말자. 실제 요청 하나를 골라 링크가 완전한지 확인하고, 정상 기준선을 하나 남겨 두고, 복구 가능한 작은 장애를 하나 만들어 보자. 장애를 제거한 뒤에는 건강 검사가 UP이고, 검색이 200을 반환하며, 핵심 소요 시간이 기준선 근처로 돌아올 때까지 이번 점검이 끝난 것이 아니다.
모니터링의 종착점은 "데이터를 보는 것"이 아니라, 잘못된 점검 방향으로 한 번 덜 가는 것이다. RAG라면 먼저 모델, 벡터 검색, 네트워크를 분리해서 보자
노력하는 작은 비
공식 계정 |【링모AI탐색실】
331
글
350k
읽음
653
팬