Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions backend/embedding-server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from contextlib import asynccontextmanager, contextmanager
from pathlib import Path

from fastapi import FastAPI, HTTPException, Response
from fastapi import FastAPI, HTTPException, Request, Response
from FlagEmbedding import BGEM3FlagModel
from prometheus_client import (
CONTENT_TYPE_LATEST,
Expand Down Expand Up @@ -409,7 +409,14 @@ def health():


@app.get("/metrics", include_in_schema=False)
def metrics():
def metrics(request: Request):
# 1. 배포 환경은 관측 VM 주소를 명시해 지표만 제한한다. 임베딩 요청은 기존 앱 경로를 유지한다.
allowed_clients = os.getenv("EMBEDDING_METRICS_ALLOWED_CLIENTS", "")
if allowed_clients:
allowed = {client.strip() for client in allowed_clients.split(",") if client.strip()}
if request.client is None or request.client.host not in allowed:
raise HTTPException(status_code=403, detail="Metrics access denied")
# 2. 허용된 관측 클라이언트에만 본문과 지표를 반환한다.
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)


Expand Down
12 changes: 12 additions & 0 deletions backend/embedding-server/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,18 @@ def test_metrics_exposes_request_admission_memory_and_process_series(client):
assert "process_resident_memory_bytes" in response.text


def test_metrics_restricts_only_metrics_to_configured_observer(monkeypatch):
monkeypatch.setenv("EMBEDDING_METRICS_ALLOWED_CLIENTS", "observer-test")
embedding_server.model = Mock()

app_client = TestClient(embedding_server.app)
assert app_client.get("/metrics").status_code == 403
assert app_client.get("/health").status_code == 200

monkeypatch.setenv("EMBEDDING_METRICS_ALLOWED_CLIENTS", "testclient")
assert app_client.get("/metrics").status_code == 200


@pytest.mark.parametrize(
("raw_limit", "expected"),
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

Expand All @@ -26,13 +27,15 @@
import com.opensource.docgrid.global.exception.DocGridException;
import com.opensource.docgrid.global.exception.ErrorCode;

import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;

/**
* Ollama {@code /api/generate}를 호출해 프롬프트로부터 답변을 생성하는 순수 HTTP 클라이언트 (F-RAG-02).
*
* <p>검색 결과가 있는지, LLM 호출을 생략할지(NO_CONTEXT) 판단하지 않는다 — 항상 주어진 프롬프트를 그대로
* 전송한다. 그 판단은 이 클라이언트를 호출하는 쪽(RagFacade)의 책임이다.
* 전송한다. 그 판단은 이 클라이언트를 호출하는 쪽(RagFacade)의 책임이다. HTTP 호출 결과만
* 낮은 카디널리티의 Micrometer 지표로 기록하며 프롬프트·답변·사용자 식별자는 기록하지 않는다.
*/
@Slf4j
@Service
Expand Down Expand Up @@ -71,6 +74,7 @@ public class OllamaClient {
private final int repeatLastN;
private final Duration generateDeadline;
private final RestClient restClient;
private final MeterRegistry meterRegistry;

public OllamaClient(
@Value("${ollama.model}") String model,
Expand All @@ -81,7 +85,8 @@ public OllamaClient(
@Value("${ollama.repeat-penalty}") double repeatPenalty,
@Value("${ollama.repeat-last-n}") int repeatLastN,
@Value("${ollama.generate-deadline}") Duration generateDeadline,
@Qualifier("ollamaRestClient") RestClient restClient
@Qualifier("ollamaRestClient") RestClient restClient,
MeterRegistry meterRegistry
) {
this.model = model;
this.keepAlive = keepAlive;
Expand All @@ -92,6 +97,39 @@ public OllamaClient(
this.repeatLastN = repeatLastN;
this.generateDeadline = generateDeadline;
this.restClient = restClient;
this.meterRegistry = meterRegistry;
}

/**
* 생성 호출 하나를 완료·부분 답변·실패 중 정확히 한 결과로 계측한다.
* RAG Job의 최종 상태와 달리 이 지표는 Ollama HTTP 호출 자체만 나타낸다.
*/
public OllamaGenerateResult generate(String prompt) {
long startNanos = System.nanoTime();
String outcome = "failure";
OllamaGenerateResult result = null;
try {
// 1. 기존 스트림 처리와 예외 계약을 바꾸지 않고 답변 생성만 수행한다.
result = generateAnswer(prompt);
outcome = result.answerText().endsWith(TRUNCATION_NOTICE) ? "partial" : "complete";
return result;
} finally {
// 2. 예외가 나도 호출 건수와 체감 시간을 남긴다. 본문·모델명은 레이블로 쓰지 않는다.
meterRegistry.counter("docgrid.ollama.generate.requests", "outcome", outcome).increment();
meterRegistry.timer("docgrid.ollama.generate.duration", "outcome", outcome)
.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
if (result != null) {
// 3. Ollama 응답이 실제로 제공한 토큰 수만 기록한다. 부분 스트림의 null은 0으로 꾸미지 않는다.
recordTokens("input", result.inputTokenCount());
recordTokens("output", result.outputTokenCount());
}
}
}

private void recordTokens(String kind, Integer count) {
if (count != null && count >= 0) {
meterRegistry.summary("docgrid.ollama.generate.tokens", "kind", kind).record(count);
}
}

/**
Expand All @@ -105,7 +143,7 @@ public OllamaClient(
* 뒤바뀌면 안 된다 — 예를 들어 트리밍을 언어 혼입 제거보다 먼저 하면 아직 안 지워진 외국어
* 글자를 문장 경계로 착각할 수 있다.
*/
public OllamaGenerateResult generate(String prompt) {
private OllamaGenerateResult generateAnswer(String prompt) {
long start = System.currentTimeMillis();
long deadline = start + generateDeadline.toMillis();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
import com.opensource.docgrid.global.exception.DocGridException;
import com.opensource.docgrid.global.exception.ErrorCode;

import io.micrometer.core.instrument.simple.SimpleMeterRegistry;

@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("OllamaClient 단위 테스트")
Expand All @@ -42,11 +44,14 @@ class OllamaClientTest {
@Mock(answer = Answers.RETURNS_SELF) private RestClient.RequestBodyUriSpec requestBodyUriSpec;

private OllamaClient ollamaClient;
private SimpleMeterRegistry meterRegistry;

@BeforeEach
void setUp() {
meterRegistry = new SimpleMeterRegistry();
ollamaClient = new OllamaClient(
"qwen2.5:3b", "30m", 300, 0.3, 0.8, 1.1, 256, Duration.ofSeconds(25), restClient
"qwen2.5:3b", "30m", 300, 0.3, 0.8, 1.1, 256, Duration.ofSeconds(25), restClient,
meterRegistry
);
doReturn(requestBodyUriSpec).when(restClient).post();
}
Expand Down Expand Up @@ -82,6 +87,14 @@ void generate_success() {
assertThat(result.inputTokenCount()).isEqualTo(120);
assertThat(result.outputTokenCount()).isEqualTo(45);
assertThat(result.latencyMs()).isGreaterThanOrEqualTo(0);
assertThat(meterRegistry.get("docgrid.ollama.generate.requests")
.tag("outcome", "complete").counter().count()).isEqualTo(1);
assertThat(meterRegistry.get("docgrid.ollama.generate.duration")
.tag("outcome", "complete").timer().count()).isEqualTo(1);
assertThat(meterRegistry.get("docgrid.ollama.generate.tokens")
.tag("kind", "input").summary().totalAmount()).isEqualTo(120);
assertThat(meterRegistry.get("docgrid.ollama.generate.tokens")
.tag("kind", "output").summary().totalAmount()).isEqualTo(45);
}

@Test
Expand All @@ -97,6 +110,8 @@ void generate_hitsNumPredict_appendsTruncationNotice() {
assertThat(result.answerText())
.startsWith("1. 첫 항목 2. 둘째 항목")
.contains("답변이 길어 일부 내용이 생략됐을 수 있습니다");
assertThat(meterRegistry.get("docgrid.ollama.generate.requests")
.tag("outcome", "partial").counter().count()).isEqualTo(1);
}

@Test
Expand All @@ -119,7 +134,8 @@ void generate_hitsNumPredict_trimsToLastSentence() {
@DisplayName("데드라인 초과: 스트림을 중단하고 그때까지 받은 부분 답변에 잘림 안내를 덧붙인다")
void generate_deadlineExceeded_returnsPartialAnswer() {
ollamaClient = new OllamaClient(
"qwen2.5:3b", "30m", 300, 0.3, 0.8, 1.1, 256, Duration.ZERO, restClient
"qwen2.5:3b", "30m", 300, 0.3, 0.8, 1.1, 256, Duration.ZERO, restClient,
meterRegistry
);
doReturn(requestBodyUriSpec).when(restClient).post();
givenStreamBody("""
Expand Down Expand Up @@ -262,6 +278,10 @@ void generate_serverUnavailable_throwsException() {
assertThatThrownBy(() -> ollamaClient.generate("질문"))
.isInstanceOf(DocGridException.class)
.hasFieldOrPropertyWithValue("errorCode", ErrorCode.RAG_SERVICE_UNAVAILABLE);
assertThat(meterRegistry.get("docgrid.ollama.generate.requests")
.tag("outcome", "failure").counter().count()).isEqualTo(1);
assertThat(meterRegistry.get("docgrid.ollama.generate.duration")
.tag("outcome", "failure").timer().count()).isEqualTo(1);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import com.opensource.docgrid.global.config.OllamaServerConfig;
import com.sun.net.httpserver.HttpServer;

import io.micrometer.core.instrument.simple.SimpleMeterRegistry;

/**
* 운영 설정({@link OllamaServerConfig})으로 만든 실제 RestClient가 Ollama에 보내는 요청의 전송 형식을 검증한다.
*
Expand Down Expand Up @@ -58,7 +60,8 @@ void setUp() throws IOException {
ReflectionTestUtils.setField(config, "connectTimeout", Duration.ofSeconds(2));
ReflectionTestUtils.setField(config, "readTimeout", Duration.ofSeconds(5));
ollamaClient = new OllamaClient(
"qwen2.5:3b", "30m", 300, 0.3, 0.8, 1.1, 256, Duration.ofSeconds(25), config.ollamaRestClient()
"qwen2.5:3b", "30m", 300, 0.3, 0.8, 1.1, 256, Duration.ofSeconds(25),
config.ollamaRestClient(), new SimpleMeterRegistry()
);
}

Expand Down
18 changes: 18 additions & 0 deletions docs/test-results/evidence/issue-479/rag479-gcp-scrape-r1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# GCP BGE 수집·접근 경계 — `rag479-gcp-scrape-r1`

- 목적: BGE 임베딩 API는 앱에 계속 열어 두면서 `/metrics`는 관측 VM만 읽게 하고, Prometheus/Grafana가 실제 수집하는지 확인한다.
- 실행 위치·시각: GCP 앱 A, BGE CPU VM, 관측 VM. 2026-10-10 03:37~03:56 UTC.
- 성공 기준: 앱→BGE `/health` 200·`/metrics` 403, 관측 VM→`/metrics` 200, Prometheus `up=1`, 실임베딩 후 요청 수 증가, Grafana 자료원 경유 값 일치.

| 절차·명령 요약 | 결과 | 해석 |
| --- | --- | --- |
| BGE 앱에 `EMBEDDING_METRICS_ALLOWED_CLIENTS`를 관측 VM으로 지정한 이미지를 적용하고 컨테이너 readiness 확인 | `/health` **200**, BGE 컨테이너 정상 | 임베딩 경로 유지 |
| 앱 A에서 BGE `/health`와 `/metrics` 각각 HTTP 상태 조회 | **200 / 403** | 앱은 임베딩 가능하지만 지표 본문은 차단 |
| 관측 VM에서 BGE `/metrics` 조회 | 최초 네트워크 **타임아웃**; 관측 태그에서 BGE 포트로 가는 단일 방화벽 허용 추가 후 **200** | BGE의 기존 ingress deny를 확인하고 허용 출처만 좁게 추가 |
| 기존 Prometheus config 백업, `promtool check config`, 재시작 | 첫 검사는 비root 컨테이너가 0600 임시 파일을 읽지 못해 실패; root 검사 재실행 **통과** | 본체 문법 문제가 아닌 검증 파일 권한 문제 |
| Prometheus `up{job="embedding-provider"}`와 요청 Counter 조회 | `up` **1/1**, 실제 PDF 인덱싱·검색 뒤 BGE 요청 **2건** | 대상이 단순 등록만 된 것이 아니라 실제 작업 지표가 증가 |
| Grafana 인증 API의 `ha-prometheus` 자료원 `/api/ds/query`로 같은 PromQL 조회 | BGE `up=1`, 요청 **2건** | Prometheus 값과 Grafana 경유 값 일치 |

BGE 배포는 이미 시험 VM에 있던 CPU 호환 이미지의 컨테이너에 수정 `main.py`를 넣고 새 로컬 Docker 이미지로 저장한 뒤, 기존 컨테이너를 보존한 상태에서 재생성했다. **저장소 Dockerfile로 이미지 전체를 처음부터 다시 빌드한 검증은 아니다.** VM 재생성 시에는 소스에서 이미지 빌드·배포가 별도로 필요하다.

BGE `/metrics`와 `/embed`는 동일 TCP 포트다. 방화벽만으로 URL 경로를 분리했다고 주장하지 않는다. 방화벽은 관측 VM의 포트 접근만 추가했고, 애플리케이션의 클라이언트 주소 검사가 `/metrics`를 제한한다. 관측 종료 후 BGE VM을 원래의 `TERMINATED` 상태로 되돌렸으므로 현재 시점의 `up=1`을 보장하지 않는다. 이 문서의 값은 시험 중 수집한 시계열이다.
8 changes: 8 additions & 0 deletions docs/test-results/evidence/issue-479/rag479-live-034625.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 공개 RAG 사전 실행의 네트워크 차단 — `rag479-live-034625`

- 목적: 공개 API에서 합성 계정·PDF를 사용한 지표 E2E를 시작한다.
- 실행 위치·시각: 로컬 제한 네트워크 실행 환경, 2026-10-10 03:46:25 UTC.
- 방법: 시험 스크립트의 첫 `POST /auth/signup` 단계로 공개 API에 연결한다.
- 성공 기준: 회원가입 HTTP 201 뒤 업로드·인덱싱·RAG로 진행.
- 관측: 시작 직후 `URLError`, 종료 코드 **1**. HTTP 응답을 받지 못했고 계정·문서·RAG 요청이 생성됐다는 증거가 없다.
- 해석: 샌드박스 네트워크 제한에 따른 실행 환경 실패. 접근이 허용된 환경에서 새 실행 ID `rag479-live-034630`으로 재시작했으며 이 실패를 제품 HTTP 실패율에 합치지 않는다. URL·계정·토큰 문자열은 기록하지 않았다.
18 changes: 18 additions & 0 deletions docs/test-results/evidence/issue-479/rag479-live-034630.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# 공개 PDF·RAG와 Ollama 지표 — `rag479-live-034630`

- 목적: 지표 코드가 적용된 GCP 앱 A에서 실제 공개 PDF 검색·RAG를 실행하고 Micrometer→Prometheus→Grafana의 값을 대조한다.
- 실행 위치·시각: 로컬 시험기 → 공개 Vercel API → GCP 앱 A → BGE·OpenSQL·Ollama, 2026-10-10 03:46:30~03:55:31 UTC.
- 워크로드·성공 기준: 합성 PDF **1건**, 해당 버전만 Worker 처리, 검색 결과 1건 이상, RAG 비어 있지 않은 답변, BGE 요청 증가, Ollama 결과·지연·토큰 지표 생성. 7B 완전 답변을 기대하지만 제한시간 초과 시 `partial`로 구분한다.

| UTC 시각·단계 | 관측 결과 | 해석 |
| --- | --- | --- |
| 03:46:30~35 회원가입·로그인·업로드 | 각각 HTTP **201·200·201**, 시험 문서 버전 1건 | 공개 API·DB·GCS 쓰기 경로 통과 |
| 첫 Worker 활성화 | 시험 버전 필터를 넣었으나 `worker-scope-test` 프로필 누락으로 앱이 기동을 거부; 공개 폴링에서 **503** 반복, 자동 원복 실행 | 앱 안전장치 정상 동작. 첫 시도는 실패이며 숨기지 않음 |
| 두 번째 Worker 활성화 | 필터와 시험 프로필을 함께 적용, 앱 HTTP **200** | 해당 버전만 처리하도록 정상 기동 |
| 03:52:20~27 인덱싱·검색 | `INDEXING`→`INDEXED`; 공개 검색 HTTP **200**, 결과 **4건**, RAG `PROCESSING` | BGE 실제 요청 발생 |
| 03:55:31 답변 조회 | `SUCCESS`, 답변 **69자**, 인용 **3건**, 시험 정답 마커 미포함 | 상태·본문은 비어 있지 않으나 의미적 정답은 **미입증** |
| 관측 VM Prometheus·Grafana 질의 | BGE 요청 **2건**; Ollama `partial` **1건**, 생성 시간 합계 **177.658711562초**, 출력 토큰 합계 **350개** | 7B 호출이 180초 제한 근처에서 부분 답변으로 끝났음을 수치로 확인 |

03:46:25의 별도 로컬 첫 실행 [`rag479-live-034625`](rag479-live-034625.md)는 샌드박스 네트워크 제한에 의한 `URLError`로 **HTTP 응답 전 실패**했다. 네트워크 접근이 허용된 실행 환경에서 다시 시작한 것이 이 문서의 실행이다. API 응답 본문·프롬프트·토큰·계정 비밀번호는 기록하지 않았다. 이 문서는 터미널 스트림을 사후 비식별 요약한 것으로, 실시간 원본 로그 파일이 저장됐다고 주장하지 않는다.

새 지표의 `complete`·`failure` 분기는 로컬 결정적 테스트를 통과했지만, GCP 실제 RAG 실행에서는 `partial`만 관측했다. 이전 지표 적용 전 별도 공개 E2E에서는 384자 답변이 완료됐으며, 이 실행의 `complete` 지표로 소급할 수 없다.
14 changes: 14 additions & 0 deletions docs/test-results/evidence/issue-479/rag479-local-contract-r1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# RAG 관측 코드 계약 검증 — `rag479-local-contract-r1`

- 목적: Ollama 결과별 호출·시간·토큰 지표와 BGE `/metrics` 접근 제한이 기존 응답 계약을 깨지 않는지 확인한다.
- 실행 위치·개정: 로컬 격리 작업 트리 `feat/479`, Java 17/Gradle 및 기존 BGE 런타임 Docker 이미지.
- 성공 기준: 관련 Java·Python 테스트 모두 통과, Prometheus 설정 문법 통과, 민감한 프롬프트·답변·사용자 ID가 새 레이블에 없음.

| 명령·방법 | 결과 요약 | 판정 |
| --- | --- | --- |
| `./backend/gradlew -p backend test --tests '*OllamaClientTest' --tests '*OllamaClientWireContractTest' --no-daemon` | Java **16건 통과**, 실패 0건 | complete·partial·failure 호출과 시간, 제공된 토큰 값 기록 검증 |
| 기존 BGE 이미지에 수정한 `main.py`·`test_main.py`를 읽기 전용 마운트하고 `python -m pytest -q test_main.py` | Python **23건 통과**, 경고 2건 | `/metrics` 비허용 403·허용 200과 기존 임베딩 계약 통과 |
| `docker run --rm --entrypoint=promtool -v "$PWD/monitoring/prometheus:/etc/prometheus:ro" prom/prometheus:v3.5.5 check config /etc/prometheus/prometheus.yml` | config **성공**, 규칙 3개 파일·20개 규칙 확인 | file-discovery 추가 후 문법 통과 |
| `./backend/gradlew -p backend bootJar --no-daemon` | JAR 생성 성공, SHA-256 `153bb7f1cb343784112e3b6652955870e84d18361fd56521e2228b0e25144120` | GCP A/B에 동일 파일 적용 |

첫 로컬 `python3 -m pytest`는 호스트 Python에 pytest가 없어 종료 코드 1이었다. 시험 코드를 변경한 실패가 아니라 실행 환경 의존성 문제였고, 기존 BGE 이미지 안에서 pytest를 설치해 재실행했다. 이어서 기존 이미지의 TestClient가 `client=` 인자를 지원하지 않아 최초 단독 점검이 `TypeError`로 실패했다. 테스트는 해당 런타임이 지원하는 기본 클라이언트로 수정했고 최종 23건이 통과했다. 이 파일은 시험 도구 출력에서 확인한 비식별 요약이며 실시간 raw 터미널 로그 파일은 아니다.
Loading
Loading