문서를 색인해 두고, 질문이 오면 근거와 함께 답하는 서버.
우아한형제들 기술블로그 「RAG, 들어는 봤는데… 내 서비스엔 어떻게 쓰지?」의
6단계를 따라 만들었다. 13단계는 산출물이 문서라 10장에 요약하고, 46단계를 코드로 옮겼다.
| 스택 | Java 17 · Spring Boot 3.5.15 · Spring AI 1.1.8 · Gradle 8.14 |
| 모델 | Titan Text Embeddings V2(1024차원) · Claude Sonnet 5 · Claude Haiku 4.5 |
| 저장소 | PostgreSQL + pgvector (HNSW / 코사인) · 스키마는 Flyway가 소유 |
| 테스트 | 186개 + Docker 13개 |
| 01 · 왜 RAG인가 | 02 · 세 파이프라인 | 03 · 경계 |
| 04 · 색인 | 05 · 검색 | 06 · 근거가 부족할 때 |
| 07 · 평가 | 08 · 돌려보기 | 09 · 아직 확인하지 못한 것 |
| 10 · 의사결정 |
판단 기준은 하나다. 모델에 컨텍스트를 그냥 넣는 것만으로 되는가.
그것으로 충분하면 RAG은 과하다.
문서가 수십 건일 때는 사람이 골라 붙여넣는 편이 빨랐다. "어느 문서를 붙여야 하는지"를 매번 사람이 판단해야 하는 시점부터 그 방식이 한계에 닿았다. 그때 RAG이 필요해졌다.
색인은 미리 한 번, 생성은 질의마다, 평가는 재고 싶을 때.
%%{init: {'theme':'base','fontFamily':'BMDOHYEON, BMHANNAPro, BMJUA, Do Hyeon, Jua, Apple SD Gothic Neo, Malgun Gothic, Noto Sans KR, sans-serif','themeVariables':{'primaryColor':'#FAF9F5','primaryTextColor':'#191919','primaryBorderColor':'#8A8577','lineColor':'#8A8577','clusterBkg':'#F7F6F1','clusterBorder':'#A39B8B','edgeLabelBackground':'#FAF9F5','titleColor':'#191919'}}}%%
flowchart LR
DOC["문서<br/>로컬 폴더 · Google Drive"]
IDX["색인<br/>indexing"]
STORE["벡터 인덱스<br/>+ 부모 청크"]
Q["질문"]
GEN["생성<br/>generation"]
ANS["답변 + 출처"]
EVAL["평가<br/>evaluation"]
DOC --> IDX --> STORE
Q --> GEN
STORE --> GEN --> ANS
ANS -.->|"evaluate=true"| EVAL
| 파이프라인 | 단계 |
|---|---|
| 색인 | 로딩 → 청킹 → 임베딩 → 저장 |
| 생성 | 검색 → 증강 → 생성 |
| 평가 | 컨텍스트 관련성 · 답변 충실성 · 답변 관련성 |
평가는 두 경로로 돈다. 질의에 evaluate=true를 주면 그 답변을 바로 잰다. 골든셋 실행은 반대
방향이다 — 평가가 질의를 부른다.
근거가 부족하면 tooling이 도구를 호출해 프롬프트에 근거를 보탠다. 따로 도는 파이프라인이 아니라
생성 도중에 끼어드는 경로다. → 06장
컨텍스트끼리 직접 부르지 않는다.
shared.kernel의 포트로 만난다. 예외는 한 쌍뿐이다.
%%{init: {'theme':'base','fontFamily':'BMDOHYEON, BMHANNAPro, BMJUA, Do Hyeon, Jua, Apple SD Gothic Neo, Malgun Gothic, Noto Sans KR, sans-serif','themeVariables':{'primaryColor':'#FAF9F5','primaryTextColor':'#191919','primaryBorderColor':'#8A8577','lineColor':'#8A8577','clusterBkg':'#F7F6F1','clusterBorder':'#A39B8B','edgeLabelBackground':'#FAF9F5','titleColor':'#191919'}}}%%
flowchart LR
subgraph CTX["바운디드 컨텍스트"]
IDX["indexing"]
GEN["generation"]
TOOL["tooling"]
EVAL["evaluation"]
end
KERNEL["shared.kernel<br/>포트 6개"]
subgraph ADP["어댑터"]
MEM["인메모리"]
PG["pgvector"]
BED["Bedrock"]
end
IDX --> KERNEL
GEN --> KERNEL
TOOL --> KERNEL
EVAL --> KERNEL
MEM -.-> KERNEL
PG -.-> KERNEL
TOOL -.->|"ToolFallbackPort"| KERNEL
BED -.-> KERNEL
실선은 포트를 쓰는 쪽, 점선은 구현하는 쪽이다. tooling은 둘 다 한다 — ToolFallbackPort를
구현하면서 저장 포트와 TimeProvider를 쓴다. 네 컨텍스트 모두 shared.kernel의 값 타입을 함께 쓴다.
파이프라인은 셋인데 컨텍스트는 넷이다. tooling은 따로 돌지 않고 생성 안에서 불린다.
포트 여섯은 VectorIndexPort · ParentChunkStore · FolderInventoryStore(저장),
EmbeddingPort(임베딩), TimeProvider(시각), ToolFallbackPort(도구 폴백)다.
generation이 evaluation을 보는 곳은 두 파일이다 —
동기 질의의 인라인 평가를 부르는 DefaultRagQueryService와 점수를 DTO로 옮기는 QueryDtoMapper다.
스트리밍 경로는 평가를 부르지 않는다. 반대 방향은 어댑터 RagQueryAnswerProbe 하나로 묶여 있다.
이 쌍은 ArchUnit 룰의 대상이 아니다.
io.github.hongjungwan.rag
shared/ ddd · kernel · config · web · infrastructure
indexing/ domain · application · infrastructure · presentation
generation/ domain · application · infrastructure · presentation
tooling/ domain · application · infrastructure · presentation
evaluation/ domain · application · infrastructure · presentation
의존 방향은 domain ← application ← presentation · infrastructure다. ArchUnit 룰 17개가 지킨다.
| 룰 | 막는 것 |
|---|---|
| 1 · 2 | domain이 바깥 레이어를, application이 infrastructure·presentation을 참조 |
| 3 · 4 | domain에 Spring 스테레오타입, domain이 벤더 SDK 참조 |
| 5 | @Autowired 필드 주입 |
| 7 | domain에서 now() · randomUUID() 호출 |
| 10 · 12 | generation → indexing, generation → tooling |
| 13 | infrastructure 밖에서 ChatModel · EmbeddingModel · VectorStore 참조 |
| 14 | evaluation.infrastructure와 shared.infrastructure.bedrock 밖에서 샘플링 파라미터 설정 |
룰 7 때문에 시각은 TimeProvider로 받고 청크 ID는 sha256으로 만든다. 나머지 룰은
DddArchitectureTest에 있다.
문서 하나가 부모 1개와 자식 N개로 쪼개진다. 검색되는 것은 자식뿐이다.
%%{init: {'theme':'base','fontFamily':'BMDOHYEON, BMHANNAPro, BMJUA, Do Hyeon, Jua, Apple SD Gothic Neo, Malgun Gothic, Noto Sans KR, sans-serif','themeVariables':{'primaryColor':'#FAF9F5','primaryTextColor':'#191919','primaryBorderColor':'#8A8577','lineColor':'#8A8577','clusterBkg':'#F7F6F1','clusterBorder':'#A39B8B','edgeLabelBackground':'#FAF9F5','titleColor':'#191919'}}}%%
flowchart LR
L["로딩"] --> P["purge<br/>조건부"] --> M["PII 마스킹"] --> S["요약<br/>추출 · LLM 없음"]
S --> C["청킹"] --> D["중복 스킵"] --> E["임베딩 + 저장"] --> F["폴더 인벤토리"]
소스는 rag.indexing.source로 갈린다(local 기본 / drive). 확장자 허용 목록은
md,txt,csv,json이고 여기 없는 파일은 읽지 않는다.
예외가 하나 있다. Drive의 Google 네이티브 문서는 확장자가 없어 MIME으로 판정하고 허용 목록과
무관하게 수집한다. Docs와 Slides는 text/plain, Sheets는 text/csv로 내보내 받는다. Sheets는
첫 시트만 나온다. prod 소스가 drive이므로 이 예외가 운영 기본 경로다.
포맷 판별은 바이트 시그니처가 한다. ZIP 매직바이트면 OOXML, %PDF면 PDF, BOM과 공백을 걷어낸
선두가 <?xml·<!doctype·<html·<table면 HTML이다. 확장자가 .csv인 HTML 표가 실제로
들어오기 때문이다.
확장자가 판별에 끼어드는 곳은 두 군데다.
.html·.htm·.xhtml·.shtml은 위 바이너리 시그니처가 아닌 한 마커가 없어도 HTML로 본다.<p>로 시작하는 조각 HTML이 평문으로 새는 것을 막는다.- 선두가 아니라 앞 512바이트 어디서든
<html·<table을 찾는 판정은md·txt·csv·json같은 텍스트 계열 확장자에 적용하지 않는다. 마크다운이 본문에 HTML 표를 인용해도 마크다운으로 남는다.
인코딩은 BOM → 엄격 UTF-8 → CP949 순으로 판별한다. UTF-8로 고정 디코드하면 CP949 파일의 한글이 깨진다.
PDF와 레거시 OLE2(.xls·.doc·.ppt)는 판별은 하되 추출기가 없다. 실패가 아니라 스킵이다.
PII 마스킹은 기본으로 켜져 있고 주민등록번호 · 이메일 · 휴대전화 세 가지를 지운다. 마스킹된 본문이 그대로 저장되고 임베딩된다. 되돌릴 수 없다.
재색인은 전삭제 후 재적재다(purge-before-index: true). 원본에서 지워진 문서의 청크가 남아
폐기된 내용을 근거로 답하는 일을 막는다. 요청 본문의 purgeBeforeIndex로 건별로 덮어쓸 수 있다.
순서가 반대면 로더 설정 오류 하나로 인덱스가 통째로 사라진다. 그래서 로딩 성공 뒤에 둔다.
purge는 folderPathPrefix를 보지 않는다. purge를 켠 채 범위를 주면 인덱스는 전량 삭제된다.
임베딩과 검색 단위는 자식뿐이다. 부모는 벡터 인덱스에 들어가지 않는다. 부모는 정규화된 원문 전체와 자식 오프셋을 들고 있다가 검색 시점에 형제 문맥을 복원하는 데 쓰인다.
| 값 | 기본값 |
|---|---|
| 청크 최대 | 5,000자 |
| 꼬리 병합 기준 | 마지막 청크가 200자 미만 |
| 경계 되짚기 범위 | 1,000자 |
| 꼬리 병합 절대 상한 | 5,200자 |
200자는 최소 길이 보장이 아니다. 마지막 청크가 200자 미만이면 직전 청크에 합치지만, 청크가 하나뿐이거나 합쳐서 5,200자를 넘으면 병합하지 않는다. 그때는 200자 미만 자식이 그대로 남는다.
경계는 하드 리밋에서 1,000자 되짚은 창을 뒤에서 앞으로 훑으며 문단 → 줄 → 문장 → 절 → 공백
순으로 첫 성공을 채택한다. 하나도 없으면 상한에서 자르고 hardCut을 남긴다. 문장·절 경계는
구두점 바로 뒤가 공백이나 개행일 때만 인정한다. 이 조건이 3.14와 v1.0을 보호한다.
CSV는 따옴표를 인식해 행 단위로 자르고 헤더 행을 모든 자식 앞에 다시 붙인다. 헤더 없는 조각은 컬럼 의미를 잃는다. 헤더 길이는 5,000자 예산에서 뺀다.
JSON은 가능하면 최상위 요소 경계(depth 1 콤마)에서 자르고 재포맷하지 않는다. 재포맷하면 부모 텍스트와 오프셋 좌표계가 어긋난다.
산문 규칙으로 폴백하는 경우가 셋 있다.
- 요소 하나가 5,000자를 넘으면 그 요소만
- 마지막 절단 지점부터 문서 끝까지가 5,000자를 넘으면 그 구간만
- depth 1 콤마가 아예 없으면 문서 전체
세 번째는 요소가 전부 5,000자 미만이어도 일어난다. 폴백한 조각은 JSON으로 파싱되지 않는다.
ID는 결정론적이다. parentId = "p" + sha256(sourceType|sourceId)[0:16],
childId = parentId + ":c" + %04d. 같은 문서를 다시 색인하면 같은 ID가 나온다.
자식 본문 앞에 문맥 프리픽스([문서] [경로] [수정] [위치] [요약])를 붙여 임베딩한다.
프리픽스 길이는 5,000자 상한에 포함하지 않는다. [요약]은 LLM이 아니라 앞 문단을 300자까지 뽑는
결정적 추출이다. 60자에 못 미치면 다음 문단을 이어 붙인다.
자식 16개를 한 upsert 작업으로 묶어 embed-concurrency(기본 4) 크기의 스레드풀에 제출한다.
16은 병렬 제출 단위이지 임베딩 배치가 아니다. Titan V2에는 배치 임베딩 API가 없어 임베딩 호출은
청크당 1회다.
런을 저장하기 전에 락을 잡는다. 실패하면 409다.
판정 근거를 런 상태로 두면, JVM이 색인 중 죽어 RUNNING으로 남은 런 때문에 이후 재색인이 영구히
막힌다. 그래서 락을 본다. Postgres 락은 rag_index_run_lock 한 행에 대한 조건부 UPDATE이고 TTL은
6시간이다.
TTL의 대가는 반대쪽 위험이다. 실제 색인이 6시간을 넘기면 락이 먼저 풀려 두 런이 겹친다.
진행 중인 런이 만료를 미는 수단은 없다 — heartbeat_at은 획득 시각을 기록만 한다. 완화책은
rag.indexing.run-lock-ttl-seconds를 가장 오래 걸리는 재색인보다 넉넉히 잡는 것뿐이다.
pg_advisory_lock을 쓰지 않았다. 색인이 수 시간 도는데 advisory lock은 세션에 묶여 커넥션을 그동안
점유하고, 커넥션이 끊기면 락이 조용히 풀려 두 런이 겹친다.
같은 질문에 같은 답이 나와야 한다. 그래서 동점 처리까지 못박는다.
%%{init: {'theme':'base','fontFamily':'BMDOHYEON, BMHANNAPro, BMJUA, Do Hyeon, Jua, Apple SD Gothic Neo, Malgun Gothic, Noto Sans KR, sans-serif','themeVariables':{'primaryColor':'#FAF9F5','primaryTextColor':'#191919','primaryBorderColor':'#8A8577','lineColor':'#8A8577','clusterBkg':'#F7F6F1','clusterBorder':'#A39B8B','edgeLabelBackground':'#FAF9F5','titleColor':'#191919'}}}%%
flowchart TB
Q["질문"] --> O["질의 최적화"] --> R["검색"] --> A["컨텍스트 조립"]
A --> J{"폴백 판정<br/>mode + 검색 통계"}
J -->|"도구 미사용"| AUG["증강"]
J -->|"도구 사용"| T["도구 호출<br/>최대 2라운드"] --> AUG
AUG --> Z{"컨텍스트도<br/>도구 결과도 0"}
Z -->|"아니오"| G["생성"] --> ANS["답변 + 출처"]
Z -->|"예"| FIX["고정 문구<br/>모델 미호출"] --> OUT["출처 없는 응답"]
Haiku 4.5로 검색 질의를 다시 쓴다. 2초 타임아웃이 걸려 있고, 실패하면 원문으로 되돌린다. 되돌린 사유는 응답 메타에 남는다.
| 가드 | 판정 |
|---|---|
TOO_SHORT |
재작성문이 max(4, 원문 × 0.2)자보다 짧다 — 과압축 |
TOO_LONG |
재작성문이 원문 × 3.0 + 200자를 넘는다 — 환각 확장 |
DRIFT |
원문과 재작성문의 문자 bigram 자카드가 0.05 미만 — 재작성이 아니라 답을 해버렸다 |
나머지는 DISABLED · TIMEOUT · LLM_ERROR · EMPTY_OUTPUT · PARSE_ERROR다. 판정은 첫 매치에서
멈추므로 사유는 항상 하나다.
기본 top-k 150, 임계값 0.6. 요청으로 덮어쓸 수 있고 범위는 top-k 11000, 임계값 01이다.
범위를 벗어나면 경로마다 다르게 처리한다 — 본문을 받는 POST는 400으로 거절하고,
GET /query/stream은 컨트롤러가 파라미터로 요청 객체를 직접 조립해 빈 검증을 타지 않으므로,
컨트롤러가 범위 안으로 접는다.
임계값 판정은 스토어가 아니라 도메인이 한다. 두 어댑터 모두 전량을 받아 온다(pgvector는
ACCEPT_ALL). 미리 걸러내면 임계값 통과 전후 개수가 같아져, 스토어 점수 규약이 틀렸을 때 그것을
드러낼 관측 지점이 사라지기 때문이다. 규약이 어긋나면 후보가 0건이 되어 모든 질의가 도구 폴백으로
흐르는데, 두 카운터가 갈라져 있어야 원인을 짚을 수 있다.
받아 온 결과에서 임계값 미만을 자르고, chunkId 중복과 본문 근사 중복을 지운 뒤
점수 → 수정일 → docId → chunkIndex 네 단계로 정렬한다. 넷을 다 쓰면 어떤 두 청크도 순서가
같아지지 않는다.
메타데이터 필터는 파일명 · 확장자 · 수정일 범위를 지원한다. docId 절은 컴파일러에 있지만 요청 DTO에 노출돼 있지 않다.
폴더 prefix만 다르게 처리한다. 색인 시점 인벤토리로 실제 경로를 펼쳐 folder_path in [...]으로
바꾼다. 펼친 결과가 200개를 넘거나 인벤토리가 비어 있으면 그 절을 필터식에서 뺀다. 대신 메모리
후처리로 거르고 filterDegraded를 세운다. 인벤토리가 비는 것은 아직 색인 전이거나 적재에 실패한
경우다.
한 문서에서 서로 다른 자식이 3개 이상 걸리면 그 문서의 블록들을 부모 원문 1블록으로 바꾼다. 부모가 2만 자 이하일 때만이다. 승격되지 않은 히트에는 앞뒤 형제 조각을 300자씩 붙여 경계에서 끊긴 문장을 잇는다. 그 형제가 그 자체로 검색 히트면 붙이지 않는다.
80만 자 예산은 그리디로 채우고 안 들어가는 블록은 통째로 건너뛴다. 블록 내부를 자르는 경우는 첫
블록 하나가 예산을 단독으로 넘길 때뿐이다. 그러지 않으면 컨텍스트가 0이 된다. 잘렸다는 사실은
blockTruncated로 응답 메타에 남는다.
출처 번호는 예산에 실제로 들어간 순서로 붙는다. 건너뛴 블록은 번호를 소비하지 않는다.
시스템 프롬프트 9개 규칙으로 컨텍스트 밖 지식을 금지하고, 근거가 없으면 "제공된 문서에서 확인할
수 없습니다."로 고정 응답하게 하며, 사실 문장마다 [출처 n]을 붙이게 한다.
컨텍스트 블록도 도구 결과도 하나도 없으면 모델을 호출하지 않고 고정 문구로 응답한다. 색인 자체가 비어 있으면 재색인을 안내하는 별도 문구가 나간다.
인용은 답변 본문에서 [출처 n]을 정규식으로 뽑아 표시한다. 검색된 전체 블록이 아니라 실제로
인용된 번호만 cited=true가 된다.
폴백 판정에 LLM이 개입하지 않는다. 검색 통계와
mode만 보는 순수 함수다.
mode는 요청 필드이고 값은 AUTO(기본) · RAG_ONLY · TOOLS 셋뿐이다. 그 밖의 값은 조용히
AUTO로 접지 않고 400으로 거절한다.
위에서부터 걸리는 첫 규칙으로 확정한다.
| 순서 | 조건 | 사유 |
|---|---|---|
| 1 | mode=TOOLS |
FORCED — 강제 사용 |
| 2 | mode=RAG_ONLY |
NONE — 이후 규칙을 보지 않는다 |
| 3 | 통과 청크 0개 | NO_PASSING_CHUNK |
| 4 | 최고 점수 < 0.70 | LOW_MAX_SCORE |
| 5 | 컨텍스트 < 500자 | THIN_CONTEXT |
| 6 | 통과 청크 < 3개 그리고 상위 평균 < 0.75 | WEAK_EVIDENCE |
| 7 | 그 밖 | NONE — 검색 근거만으로 답한다 |
6번은 AND 조건이다. 청크가 적어도 점수가 높으면 폴백하지 않는다. '상위 평균'은 통과 청크를 점수
순으로 세워 위에서 min(5, 통과 청크 수)개를 평균한 값이다.
폴백 임계값 0.70을 검색 임계값 0.6보다 높게 잡은 것은 의도다. 0.6~0.7 구간은 검색에는 통과하지만 근거로는 약하다. 그대로 답하면 오히려 틀릴 위험이 크다.
searchDriveFiles · readDriveFile · listDriveFolders · getIndexStatus 4종이다. 선택기는
RULE(어휘 규칙) / SCHEMA(LLM이 JSON으로 선택) / FORCED_TOOL_CHOICE 중 하나이며 기본은
SCHEMA다. SCHEMA가 파싱에 실패하면 1회 재시도한 뒤 RULE로 내려간다.
라운드 상한(기본 2) 외에 무진행 감지가 있다. 같은 도구를 같은 인자로 다시 고르면 즉시 멈춘다. 멈춘 이유는 컨텍스트에 안내 블록으로 남긴다. 남기지 않으면 모델이 "다 찾아봤지만 없었다"고 오해한다.
도구 실행이 실패해도 재시도하지 않고 [도구 오류] … 조회가 실패했다. 자료가 없다는 뜻이 아니다.
블록을 남긴다.
지표 셋을 각각 다른 방식으로 잰다.
| 지표 | 재는 방법 | 전역 기본 임계값 |
|---|---|---|
| 컨텍스트 관련성 | 컨텍스트를 문장으로 쪼개 심판 LLM이 "답에 필요한가"를 판정. 필요 판정 수 / 판정에 보낸 문장 수 |
0.5 |
| 답변 충실성 | 답변에서 주장을 뽑아 심판 LLM이 컨텍스트로 뒷받침되는지 판정 | 0.8 |
| 답변 관련성 | 답변에서 역질문 5개를 LLM이 만들고, 원 질문과의 임베딩 코사인 평균을 낸다 | 0.7 |
답변 관련성에서 LLM은 역질문만 만든다. 점수 자체는 임베딩 코사인이다.
표의 값은 rag.evaluation.default-thresholds의 전역 기본값이다. 골든셋 레코드는
minContextRelevance·minAnswerFaithfulness·minAnswerRelevance로 지표별로 덮어쓴다. 지정하지
않은 지표는 전역값을 쓴다. 데모 골든셋의 gs-002는 충실성만 0.6으로, gs-003은 컨텍스트 관련성만
0.0으로 덮어쓴다. 리포트의 합격 여부를 표의 값만으로 판단하면 안 된다.
컨텍스트 관련성은 분모에서 [출처 n] 블록 헤더 줄과 10자 미만 조각을 뺀다. 유효 문장이 300개를
넘으면 균등 간격 300개만 판정한다. 심판이 일부 문장을 빠뜨리면 그 문장은 '불필요'로 세고 분모는
줄이지 않는다. 분모에서도 빼면 애매한 문장을 빠뜨릴수록 점수가 올라간다. 판정 누락이 절반을 넘으면
점수를 내지 않고 JUDGE_ERROR로 내린다. 이 가드는 충실성의 주장 판정에도 똑같이 걸린다.
JSONL이다. 한 줄이 곧 한 레코드라 오류 행이 정확히 특정되고 git diff가 레코드 단위로 읽힌다.
JSON 파싱 실패 · 빈 id · 빈 question · id 중복은 넷 다 로드 즉시 예외다. 깨진 줄만 버리는 게
아니라 그 파일 전체 로드가 실패한다. 기동 시 전량 파싱하므로 하나라도 있으면 부팅이 실패한다.
빈 줄과 # 주석만 건너뛴다.
게이트는 점수만 보지 않는다. 다음도 불합격이다.
| 불합격 사유 | 왜 |
|---|---|
JUDGE_ERROR |
측정하지 못한 지표를 통과로 처리하면 심판이 전부 깨진 런이 "전건 합격"이 된다 |
충실성 CONTRADICTED |
근거를 뒤집은 답변은 점수와 무관하게 막는다 |
충실성 TRUNCATED_CONTEXT |
잘린 컨텍스트로 잰 점수는 근사값이라 합격 근거로 쓰지 않는다 |
mustNotContain 위반 · expectedKeywords 전무 |
— |
NOT_APPLICABLE · NO_CLAIMS · NO_QUESTIONS는 불합격으로 세지 않는다. 다른 실패가 없으면 그
레코드는 합격으로 집계된다. notApplicableCount는 이 세 상태 전용 카운터가 아니라 지표를
하나라도 재지 못한 레코드 수라서 JUDGE_ERROR 레코드도 함께 센다. 심판이 전부 깨진 런은
failedCount와 notApplicableCount가 같은 값으로 나온다.
평가에 Haiku 4.5를 쓰는 이유는 두 지표의 심판 프롬프트가 문장 수·주장 수에 비례해 커지기 때문이다. 호출 자체는 문장 20개씩, 주장 10개씩 배치로 묶는다. 그래도 그 토큰량이 골든셋 레코드 수만큼 곱해진다. 같은 모델을 쓰면 평가 비용이 생성 비용을 넘는다.
clone 하고
./gradlew bootRun하면 뜬다. 자격증명도 Docker도 필요 없다.
./gradlew bootRun기본 프로파일이 local이다. 웹 콘솔은 http://localhost:8081/.
색인 대상 폴더는 ./rag-data이고 데모 문서 4개(policy.md · notes.txt · sample.csv · models.json)가
저장소에 들어 있다. 자기 문서를 넣으려면 같은 폴더에 md·txt·csv·json으로 두면 된다.
.gitignore가 그 4개만 추적하고 나머지는 막는다.
기동해도 색인은 자동으로 돌지 않는다. 먼저 색인을 돌리고 질의한다. local은 관리 키가 비어
있어 필터가 등록되지 않으므로 헤더가 필요 없다.
curl -X POST localhost:8081/api/rag/index/runs \
-H 'Content-Type: application/json' -d '{}'
curl localhost:8081/api/rag/index # running=false 가 될 때까지 폴링
curl -X POST localhost:8081/api/rag/query \
-H 'Content-Type: application/json' \
-d '{"question":"청킹 최대 크기는 몇 자인가요?"}'question만 필수다. mode · topK · similarityThreshold · includeContext · evaluate ·
history · filter는 생략하면 설정 기본값을 쓴다.
local |
prod |
|
|---|---|---|
| 자격증명 | 0개 | AWS + Drive 서비스계정 |
| 문서 소스 | 로컬 폴더 | Google Drive |
| 임베딩 | 결정론적 스텁(문자 3-gram 해시, 1024차원) | Titan V2 |
| 채팅 | 결정론적 스텁 | Sonnet 5 / Haiku 4.5 |
| 저장소 | 인메모리 | PostgreSQL + pgvector |
| 도구 선택기 | RULE |
SCHEMA |
| 검색 임계값 | 0.05 | 0.6 |
| 폴백 하한 / 상위 평균 | 0.05 / 0.03 | 0.70 / 0.75 |
| 로그 레벨 | DEBUG | INFO |
local에서 색인 → 검색 → 증강 → 생성 → 평가가 끝까지 돌아야 하고 LocalRagPipelineIT가 실제 HTTP
표면에서 확인한다.
local의 임계값 0.05는 오설정이 아니다. 스텁 임베딩은 문자 3-gram 해시라 신경망 임베딩과 점수
대역이 다르다. 통합테스트 픽스처로 실측하면 정답 청크의 코사인이 0.12~0.25다. 0.6을 쓰면 검색이
항상 0건이 되어 파이프라인 뒷단이 전혀 검증되지 않는다.
같은 실측에서 어떤 정답 질의는 최고점이 0.123까지밖에 오르지 않아 무관 질의의 최고점 0.116과
구별되지 않는다. 이 스케일에서는 점수로 음성 케이스를 만들 수 없다. local의 폴백 테스트는 점수
대신 mode=TOOLS나 전부 배제되는 필터 같은 결정적 트리거를 쓴다.
cp .env.example .env
# POSTGRES_PASSWORD와 RAG_ADMIN_API_KEY는 반드시 채운다. openssl rand -hex 32
# DRIVE_SHARED_DRIVE_ID도 채운다.
mkdir -p secrets && cp <서비스계정키>.json secrets/drive-sa.json
docker compose up --buildcompose가 SPRING_PROFILES_ACTIVE=prod를 넣는다. jar로 직접 올린다면 이 값을 직접 줘야 한다.
기본값은 local이다.
| 변수 | 왜 |
|---|---|
POSTGRES_PASSWORD |
인덱스에 문서 원문이 들어간다. postgres와 backend가 이 한 값을 공유한다 |
RAG_ADMIN_API_KEY |
관리 엔드포인트의 X-API-Key. 앱은 빈 값도 허용하지만 컨테이너 배포에서는 강제한다 |
DRIVE_SHARED_DRIVE_ID |
compose가 강제하지 않는데, 비우면 공유드라이브 스코프 없이 나열해 색인이 0건으로 끝난다. 기동도 재색인도 성공하고 WARN만 남는다 |
앞의 두 개는 compose가 ${VAR:?}로 강제한다. 하나라도 비면 up은 물론 down·logs·ps까지
거부된다. compose는 서브커맨드와 무관하게 파일 전체를 보간하기 때문이다.
AWS_*가 비면 DefaultCredentialsProvider가 IRSA·인스턴스 프로파일을 찾는다. 공유드라이브는
서비스계정 이메일에 Viewer로 공유해야 한다.
DB 이미지는 pgvector/pgvector:pg16이다. 순수 postgres 이미지는 Flyway V1의 CREATE EXTENSION에서
실패한다. 그 문장 때문에 마이그레이션 계정에 확장 생성 권한이 필요하다. 관리형 DB라면 DBA가
CREATE EXTENSION vector를 미리 실행해 두면 통과한다.
backend 컨테이너는 메모리 2g, OOM 시 즉시 종료 + 힙덤프(./heapdumps), restart: unless-stopped다.
포트는 8081이다.
./gradlew test # 186개. Docker 불필요
./gradlew dockerTest # PgStoresIT 13개. Docker 필요환경변수 게이트로 두면 아무도 켜지 않는다. CI에서 한 줄도 안 돌아도 초록이 뜬다. 그래서 Docker가
필요한 테스트는 dockerTest로 뺐다. GitHub Actions가 두 태스크를 순서대로 돌린다.
docker compose up --build는 이미지 안에서 bootJar -x test를 실행한다. 이미지 빌드는 테스트를
돌리지 않는다.
Base /api/rag. 비스트리밍 응답은 StatsResponse<T>(success/data/message/timestamp) 봉투를 쓴다.
| 메서드 | 경로 | 설명 |
|---|---|---|
POST |
/query |
질의(동기) |
GET |
/query/stream |
질의(SSE). EventSource로 직접 연결. 대화 이력은 못 보낸다 |
POST |
/query/stream |
질의(SSE). 대화 이력이 필요할 때 |
POST |
/index/runs |
재색인 트리거. 202, 이미 실행 중이면 409 |
GET |
/index/runs |
최근 런 목록 |
GET |
/index/runs/{runId} |
런 상세. 없는 runId는 400 |
GET |
/index |
색인 상태 |
DELETE |
/index |
전량 삭제. 200 + 삭제 전 카운트 |
POST |
/evaluations |
골든셋 실행. 202. 이름 오타는 400, 파일 내용 오류는 409 |
GET |
/evaluations |
최근 평가 런 목록 |
GET |
/evaluations/{runId} |
리포트. 없는 runId는 400 |
GET |
/evaluations/golden-set |
골든셋 조회 |
POST |
/evaluations/adhoc |
단발 평가 |
GET |
/tools |
도구 카탈로그(조회 전용) |
GET |
/tools/fallback-policy |
현재 폴백 임계값 |
X-API-Key로 막는 것은 다음 셋뿐이다. 경로와 메서드가 정확히 일치할 때만 걸린다.
POST /api/rag/index/runs— 전량 재임베딩, 곧 과금DELETE /api/rag/index— 인덱스 전량 삭제POST /api/rag/evaluations— 심판 LLM 대량 호출
나머지는 전부 열려 있다. POST /api/rag/query와 POST /api/rag/evaluations/adhoc도 포함이다.
조회까지 키를 요구하면 콘솔을 여는 모든 사용자가 관리 키를 갖게 되어 키가 사실상 공개된다.
조회 통제는 네트워크 경계가 맡는다. 컨테이너 안에서 모든 인터페이스에 붙어야 리버스 프록시가
닿으므로 BACKEND_BIND 기본값은 0.0.0.0이다. 호스트에 직접 노출한다면 127.0.0.1로 좁혀라.
키가 비면 필터를 등록하지 않고 무엇이 열려 있는지 경고 로그로 나열한다. 인증이 꺼진 것을 모르는 상태가 제일 위험하다.
SSE는 봉투를 쓰지 않는다. 스트림에는 전체 성공·실패라는 개념이 없고, 이벤트마다 봉투를 감싸면 클라이언트가 두 번 벗겨야 한다. 이벤트 이름으로 구분한다.
meta → retrieval → citations → context? → tool* → token* → done
오류는 어느 시점에서든 error 1건 후 종료한다. 검색·조립에서 예외가 나면 meta조차 보내지 않는다.
5분 타임아웃도 같은 계약을 따라 error를 내보낸다.
context는 includeContext=true일 때만 1회 발생한다. 15초 주기 :ping은 데이터 이벤트가 아니다.
문서가 코드보다 앞서가면 안 되므로 적는다.
- prod 기동은 실제로 확인된 적이 없다.
ProdWiringDefinitionTest는 빈 정의와 조건 평가만 본다. 초록이어도 "prod가 뜬다"는 뜻이 아니라고 테스트 스스로 밝힌다. PgVectorIndexAdapter의upsert·search·count·deleteAll은 어떤 테스트에서도 실행되지 않는다. 즉 벡터 검색 경로 자체가 미검증이다.PgVectorScoreScaleTest가 잡는 것은 점수를 그대로 넘기는지 여부뿐이다.PgParentChunkStore.deleteAll()도 마찬가지다 —DELETE /api/rag/index와 재색인 purge가 타는 경로인데 테스트가 부르지 않는다.PgStoresIT가 실행하는 것은 부모 청크 적재·조회, 색인 런 저장·조회, 락 4종, 폴더 인벤토리, 스키마 게이트다. 개발 머신에 Docker가 없어 GitHub Actions가 그 실행 지점이다.FORCED_TOOL_CHOICE경로는 런타임에서 확인되지 않았다. 강제tool_choice와thinking:disabled조합이 Bedrock에서 통하는지 AWS 모델 카드와 Anthropic 문서가 엇갈려 기본값을SCHEMA로 둔다.output_config.effort는 전송하지 않는다.additionalModelRequestFields로 넘기는 것이 허용되는지 확정하지 못해 기본값에 의존한다.- 청킹 알고리즘(
ChunkBoundaries·FixedSizeParentChildChunker·TextNormalizer)에 단위 테스트가 없다. - 골든셋은 데모용 3건이 전부다.
prod도 같은 클래스패스 골든셋을 보므로 운영 골든셋은RAG_GOLDEN_SET_PATH로 디렉터리를 주입해야 한다. 그런데 compose는 이 변수를 backendenvironment에 넣지 않고 골든셋용 볼륨도 없다..env에만 적으면 컨테이너에 전달되지 않고 경고도 없이 데모 3건이 계속 쓰인다. compose 배포라면environment와volumes에 함께 넣어야 한다. local에서 골든셋 게이트는 전건 불합격이 정상이다. 스텁 임베딩과 스텁 채팅으로는 점수가 임계값에 닿지 않는다. 통합테스트도 합격이 아니라 오류 0건만 확인한다.
-
정기 재색인은
rag.indexing.schedule-enabled=true와 비어 있지 않은 cron이 둘 다 있어야 켜진다. 하나만 있으면 스케줄러 빈이 만들어지지 않는다. cron 타임존은 Asia/Seoul 고정이다. -
compose는
RAG_INDEXING_CRON을 항상 컨테이너에 설정한다(.env에 없으면 빈 문자열). Spring의${X:default}는 프로퍼티가 없을 때만 기본값을 쓰고 빈 문자열은 존재하는 값이다. compose 배포에서는.env에 cron을 직접 적어야 스케줄러가 켜진다. -
콘솔의 '재색인 실행'은 색인 패널의 범위 입력만 읽는다. 질의 필터와 공유하지 마라. 범위를 준 재색인이 왜 전량 삭제로 이어지는지는 04장에 있다.
-
콘솔의 관리 키는
sessionStorage에만 둔다. 탭을 닫으면 사라진다. -
평가 리포트는
./build/eval-reports에 파일로 떨어진다. 컨테이너에서는 볼륨이 아니라 재생성 시 사라진다. -
scripts/reset-index.sh [BASE_URL]— 전량 삭제 → 재색인 트리거 → 상태 조회. 재색인은 비동기라 폴링은 사용자 몫이다. -
색인 중에 컨테이너를 내리면 락이 최대 6시간 남는다.
compose down도 마찬가지다. 색인은@Async스레드에서 도는데 종료가 그것을 기다리지 않기 때문이다. 그 뒤 재색인은 409가 되고GET /api/rag/index는running=true로 보인다.reset-index.sh도 이 상태는 복구하지 못한다 — 스크립트의POST /index/runs단계가 같은 409로 죽는다. 락을 직접 풀어야 한다.UPDATE rag_index_run_lock SET holder_run_id = NULL, acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL WHERE lock_name = 'index-run';
TTL은
rag.indexing.run-lock-ttl-seconds(기본 21600)다. 기다려도 풀린다. -
Drive 로더에는 상한 셋이 있다 — 파일 5,000개 · 총 512MB · 파일당 10MB. 넘는 분량은 잘리는 게 아니라 스킵되고, 스킵 사유는
GET /api/rag/index의lastLoadSkipped로 나온다. 색인 결과가 기대보다 적으면 여기부터 본다.
고른 이유보다, 빠뜨리면 조용히 깨지는 것들을 적는다.
포트 구현은 rag.indexing.store 프로퍼티로 고른다(@ConditionalOnProperty, 기본 memory).
프로파일이 아니다. 그 값을 세팅하는 것이 프로파일 yml이다 — application-prod.yml이
store: postgres를, 공통 yml이 memory를 넣는다.
| 포트 | 인메모리 | Postgres | 테이블 |
|---|---|---|---|
VectorIndexPort |
InMemoryVectorIndexAdapter |
PgVectorIndexAdapter |
rag_chunk |
ParentChunkStore |
InMemoryParentChunkStore |
PgParentChunkStore |
rag_parent_chunk |
FolderInventoryStore |
InMemoryFolderInventoryStore |
PgFolderInventoryStore |
rag_folder_inventory |
IndexRunRepository |
InMemoryIndexRunStore |
PgIndexRunStore |
rag_index_run · rag_index_run_lock |
앞의 세 포트는 shared.kernel에 있고 IndexRunRepository는 indexing.domain이 자기 안에서 쓰는
포트다. 그래서 Postgres 구현도 PgIndexRunStore만 indexing.infrastructure에 있다.
rag_chunk의 컬럼 계약은 Spring AI PgVectorStore가 소유한다. 벡터 인덱스는 HNSW
vector_cosine_ops(m=16, ef_construction=200)다.
local에서 프로퍼티만 postgres로 바꾸면 부팅이 실패한다. pgvector 설정 일체가
application-prod.yml에만 있고, application-local.yml은 vectorstore.type: none으로
PgVectorStore 자동설정을 끈 위에 DataSource·Flyway 자동설정까지 제외하기 때문이다. 그 셋을 함께
옮겨야 한다.
점수 스케일이 가장 조용히 깨지는 지점이었다. pgvector는 score = 1 - distance이고 코사인
거리는 1 - cos이므로 점수가 곧 코사인이다. 어댑터는 점수를 그대로 넘기고
PgVectorScoreScaleTest가 그것을 못박는다.
Redis는 (1 + cos) / 2를 줘서 2s - 1 환산이 필요했다. 그 환산을 pgvector 어댑터에 되살리면 점수가
2cos - 1이 되어 임계값 0.6이 실제로는 코사인 0.8을 요구한다. 근거가 충분한 후보까지 탈락하고
폴백이 상시 발동한다. 거꾸로 Redis 스케일 점수를 환산 없이 쓰면 0.6이 코사인 0.2를 뜻해 세 배
관대해진다. 어느 방향이든 오류는 나지 않고 답변 품질만 나빠진다.
빠뜨리면 조용히 깨지는 설정이 둘 더 있다.
id-type: TEXT— 기본값인 UUID 모드는UUID.fromString(chunkId)를 호출하는데 이 프로젝트의 chunkId는p<sha16>:c0000이라 UUID가 아니다. 빠뜨리면 첫 색인이 통째로 죽고 스택트레이스에 원인 단서가 없다.hnsw.ef_search— 기본값 40인데 top-k는 150이다. 그대로 두면 검색이 조용히 40건에서 잘린다. 세션 GUC라 SQL 파일에 담을 수 없어spring.datasource.hikari.connection-init-sql로SET hnsw.ef_search = 200을 건다. 이 설정은application-prod.yml에만 있다.
기동 시 PgIndexSchemaCheck가 pgvector 확장 · 테이블 · id 컬럼 타입 · 임베딩 차원을 확인하고
어긋나면 부팅을 실패시킨다. HNSW 인덱스 부재만 경고다. 다만 id-type 프로퍼티 자체는 런타임에
보지 않는다. Flyway가 만든 컬럼은 항상 text라, 프로퍼티를 UUID로 두면 검사를 전부 통과하고 부팅도
성공한 뒤 첫 upsert에서 죽는다. 그 조합은 ProdWiringDefinitionTest가 막는다.
Claude Sonnet 5에는 지켜야 할 제약이 있다.
temperature·top_p·top_k를 비기본값으로 보내면 400이다. 그래서- 어떤
application*.yml에도spring.ai.bedrock.converse.chat.options.*를 쓰지 않는다 spring.ai.model.chat=none으로 자동설정을 끄고BedrockChatConfig가 빈을 직접 만든다. Spring AI 1.1.8의BedrockConverseProxyChatProperties가temperature=0.7/maxTokens=300을 final 필드에 하드코딩해서 프로퍼티로는 무력화할 수 없다- 생성 빈에는
model과maxTokens만 세팅하고, 기동 시assertNoSamplingParams가 검증해 위반이면 컨텍스트를 띄우지 않는다 - 답변의 일관성은 파라미터가 아니라 시스템 프롬프트의 문장으로 잡는다
- 어떤
- adaptive thinking이 기본으로 켜져 있다.
max_tokens는 사고와 응답의 합산 상한이라 16,000으로 둔다. - 강제
tool_choice에는thinking:{"type":"disabled"}가 함께 필요하다. Spring AI가toolChoice를 노출하지 않아 이 경로만BedrockRuntimeClient로 직접 구현했다. budget_tokens와 assistant prefill은 400이다.- Haiku 4.5는 반대다.
temperature는 허용되고output_config.effort는 오류다. 이 비대칭 때문에 ChatModel 빈이 용도별로 셋(generationChatModel/toolingChatModel/evaluationChatModel)이다.temperature 0.0은 평가 빈에만,thinking:disabled는 도구 빈에만 붙는다.
LangChain과 기술적 우위를 따진 결과가 아니다. 익숙한 스택이 더 빠르다고 봤다. 혼자 만들더라도 리뷰를 받으려면 읽을 수 있는 스택이어야 한다.
Spring AI 2.0.0은 Boot 4와 Jackson 3를 요구하고 spring-ai-advisors-vector-store 좌표가 사라져서
쓰지 않았다. 근거는 build.gradle.kts 주석에 있다.
여러 시스템을 돌지 않고 한 곳에서 자연어로 묻고 출처가 붙은 답을 받는다. 근거가 부족하면 도구 호출로 자동 전환한다. 컨텍스트 윈도 초과를 막고 개인정보를 마스킹한다.
정량 목표를 문서에만 두면 달성 여부를 판단할 수 없다. 그래서 비스트리밍 POST /query 응답에
timings(optimize/retrieve/assemble/tool/augment/generate/evaluate/total)를 싣는다. SSE에서는
done의 latencyMs·generateMs가 그 자리를 대신한다. 두 경로 모두 같은 8단계를 로그에 남기되
스트리밍은 generate·evaluate가 0으로 찍힌다.
01장과 이 장의 판단 틀·정량 목표는 원문 글에서 가져와 이 프로젝트에 대입한 것이다. 그 부분의 저작권은 원저작자에게 있고 이 저장소의 MIT 라이선스는 여기 적용되지 않는다.
다이어그램 글꼴은 배달의민족 도현 → 한나 → 주아 순으로 지정한다. 설치돼 있지 않으면 시스템 한글 폰트로 폴백한다. GitHub는 마크다운에서 CSS를 제거하므로 본문 글꼴은 지정할 수 없다.