Spring Boot 기반 사내 문서 QA 시스템. 문서를 수집(Ingestion)하고 하이브리드 검색(BM25 + Vector)으로 관련 문서를 찾아 LLM에 전달해 답변을 생성한다. 입출력 소프트 가드레일로 프롬프트 인젝션과 환각(Hallucination)을 방어한다.
| 분류 | 기술 |
|---|---|
| Language / Runtime | Java 25 |
| Framework | Spring Boot 4.0.5 |
| AI | Spring AI 2.0.0-M4 (OpenAI Chat, Milvus) |
| 키워드 검색 | pg_search (ParadeDB BM25) |
| 벡터 검색 | Spring AI Milvus VectorStore (Cosine) |
| DB | PostgreSQL (BM25) + Milvus (Vector) |
| ORM | Spring Data JPA / Hibernate |
| 임베딩 | Douzone 사내 API (openai 전환 가능) |
| LLM / 가드레일 | OpenAI gpt-4o-mini |
| 문서 파싱 | Apache Tika |
| 테스트 | JUnit 5 + Mockito + MockMvc |
| 기타 | Lombok, Jackson 3.x |
헥사고날 아키텍처(Ports & Adapters) 기반으로 설계되어 있다.
controller/ ← HTTP 인바운드 어댑터
service/ ← 비즈니스 로직 (도메인 오케스트레이션)
port/ ← 추상화 인터페이스 (서비스가 의존)
adapter/ ← 포트 구현체 (DB, 외부 API)
domain/ ← 순수 도메인 모델 (record)
support/ ← 유틸리티 (ContextBuilder, RrfRankFusion, SearchLogger)
exception/ ← 예외 계층 (RagException, 서비스별 예외, GlobalAdvice)
config/ ← Spring 설정
| 인프라 | 역할 |
|---|---|
| PostgreSQL (ParadeDB) | BM25 키워드 검색 (rag_chunks), 검색 로그 (search_logs) |
| Milvus | Cosine 유사도 벡터 검색 (Spring AI VectorStore 추상화) |
HTTP 요청
└─ RagAnswerService.answer(RagAnswerRequest)
├─ 1) InputGuardrailPort ← 프롬프트 인젝션 차단
├─ 2) QueryPreprocessPort ← keywordQuery(BM25) + vectorQuery(HyDE) 생성
├─ 3) HybridSearchService ← BM25(keywordQuery) + Milvus(vectorQuery) + RRF + Rerank
├─ 4) ContextBuilder ← dedup / trim / sanitize
├─ 5) ChatClient ← OpenAI gpt-4o-mini 호출
└─ 6) OutputGuardrailPort ← 환각 감지 / 근거 검증
- Java 25
- Docker & Docker Compose
- OpenAI API Key
| 변수 | 설명 | 필수 |
|---|---|---|
OPENAI_API_KEY |
OpenAI API 키 (Chat LLM + 가드레일) | ✅ |
DOUZONE_EMBEDDING_URL |
Douzone 사내 임베딩 API URL | ✅ |
DB_PASSWORD |
PostgreSQL 패스워드 (미설정 시 ragpass) |
- |
MILVUS_HOST |
Milvus 호스트 (미설정 시 localhost) |
- |
MILVUS_PORT |
Milvus gRPC 포트 (미설정 시 19530) |
- |
MILVUS_USERNAME |
Milvus 접속 계정 (미설정 시 root) |
- |
MILVUS_PASSWORD |
Milvus 패스워드 (미설정 시 milvus, 운영 시 반드시 변경) |
- |
# 1. PostgreSQL(ParadeDB) + Milvus 컨테이너 시작
docker compose up -d
# 2. 환경변수 설정
export OPENAI_API_KEY=sk-...
export DOUZONE_EMBEDDING_URL=https://...
# 3. 로컬 프로파일로 실행
# (인메모리 키워드·벡터 검색, 가드레일/전처리 비활성화, 스키마 자동 초기화)
./gradlew bootRun --args='--spring.profiles.active=local'
# 전체 스택 실행 (PostgreSQL BM25 + Milvus 벡터 + 가드레일 + LLM 전처리)
./gradlew bootRun로컬 프로파일 (
--spring.profiles.active=local):
rag.keyword-search-type=memory→ PostgreSQL 불필요rag.vector-store-type=memory+spring.autoconfigure.exclude→ Milvus 자동설정 비활성화,SimpleVectorStore(인메모리) 사용- Docker 없이도 기본 동작 확인 가능
./gradlew testPOST /api/ingest/text
Content-Type: application/json
{
"docId": "doc-001",
"source": "규정집.pdf",
"domain": "hr",
"version": "2024.01",
"tenantId": "default",
"content": "문서 내용..."
}
POST /api/ingest/file
Content-Type: multipart/form-data
file=@규정집.pdf docId=doc-001 source=규정집.pdf domain=hr version=2024.01
GET /api/documents?tenantId=default&domain=hr ← 문서 목록 조회
GET /api/documents/{docId} ← 특정 문서 정보 (없으면 404)
DELETE /api/documents/{docId} ← rag_chunks(BM25) + Milvus 동시 삭제 → 204
문서 갱신은
DELETE후/api/ingest/*재호출로 처리한다. 인제스트 중 키워드 색인이 실패하면, 직전에 저장된 Milvus 벡터 데이터도 즉시 롤백해 반쪽 저장 상태를 남기지 않는다.
POST /api/rag/answer
Content-Type: application/json
{
"query": "연차 신청 방법이 궁금합니다",
"tenantId": "default",
"domain": "hr",
"sortByLatest": false
}
응답 예시
{
"requestId": "550e8400-...",
"answer": "연차 신청은 [S1]에 따라 사내 포털에서 ...",
"citations": [
{
"citeKey": "S1",
"docId": "doc-001",
"source": "규정집.pdf",
"snippet": "연차 신청은 사내 포털에서..."
}
]
}POST /api/rag/answer/stream
Content-Type: application/json
Accept: text/event-stream
{ "query": "연차 신청 방법", "tenantId": "default", "domain": "hr" }
이벤트 타입:
| 이벤트 | 내용 |
|---|---|
token |
LLM이 생성하는 토큰 조각 (실시간 전송) |
done |
최종 requestId + citations JSON |
error |
에러 발생 시 메시지 |
스트리밍은 입력 가드레일 적용, 출력 가드레일은 생략(완전한 답변이 필요).
PATCH /api/rag/feedback/{requestId}
Content-Type: application/json
{ "accepted": true }
search_logs.answer_accepted 를 업데이트한다. cosine threshold 튜닝에 사용되는 핵심 신호다.
- 한쪽 검색 채널만 실패하면 남은 채널 결과로 계속 처리한다.
- 키워드 검색과 벡터 검색이 모두 실패하면 일반 fallback 답변으로 숨기지 않고
503 Service Unavailable과S0001을 반환한다.
GET /api/analytics/search?from=2026-04-01T00:00:00Z&to=2026-04-07T23:59:59Z
from/to 미입력 시 최근 7일 기준.
응답 예시
{
"totalRequests": 1024,
"feedbackCount": 320,
"overallAcceptanceRate": 0.84,
"scoreBuckets": [
{ "scoreLow": 0.6, "scoreHigh": 0.7, "total": 120, "acceptanceRate": 0.72 },
{ "scoreLow": 0.7, "scoreHigh": 0.8, "total": 280, "acceptanceRate": 0.88 }
],
"channelStats": [
{ "channel": "fused", "count": 512, "avgScore": 0.74 },
{ "channel": "lexical", "count": 256, "avgScore": 0.65 }
],
"dailyStats": [
{ "date": "2026-04-01", "requestCount": 148 }
]
}scoreBuckets 의 acceptanceRate 를 보고 수락률이 낮은 점수 구간을 파악해 rag.vector-threshold 를 조정한다.
GET /api/analytics/scatter?from=2026-04-01T00:00:00Z&to=2026-04-07T23:59:59Z&limit=5000
from/to 미입력 시 최근 7일, limit 미입력 시 5000건 기준.
응답 예시
[
{
"requestId": "550e8400-...",
"docId": "doc-001",
"chunkId": "chunk-abc",
"cosineScore": 0.82,
"rank": 1,
"usedInPrompt": true,
"answerAccepted": true,
"channel": "vector",
"createdAt": "2026-04-05T10:30:00Z"
}
]인터랙티브 산점도 UI는 서버 실행 후 /scatter.html 에서 확인할 수 있다.
| 기능 | 설명 |
|---|---|
| X축 전환 | Rank Position / Time |
| 색상 기준 | Channel(lexical/vector/fused), Used in Prompt, Feedback |
| Threshold 슬라이더 | cosine threshold 라인을 실시간 조정하며 상/하 분포 확인 |
| 통계 바 | 총 데이터 수, 평균 점수, 임계값 상·하 건수, 수락률 |
| 기간·건수 필터 | from, to, limit 파라미터로 조회 범위 조절 |
src/main/resources/application.yaml
| 프로퍼티 | 기본값 | 설명 |
|---|---|---|
rag.embedding.type |
douzone |
임베딩 모델 선택 (douzone | openai) |
rag.keyword-search-type |
postgres |
키워드 검색 어댑터 (postgres | memory) |
rag.vector-store-type |
milvus |
벡터 저장소 어댑터 (milvus | memory) |
rag.guardrail.enabled |
true |
소프트 가드레일 활성화 여부 |
rag.query-preprocess.enabled |
true |
쿼리 전처리 활성화 여부 |
rag.top-k-final |
5 |
최종 검색 결과 수 |
rag.vector-threshold |
0.6 |
벡터 유사도 하한선 |
rag.chunk.size |
600 |
고정 청킹 크기 (토큰 수) |
rag.chunk.strategy |
semantic |
청킹 전략 (semantic | fixed) |
rag.sse-timeout-ms |
120000 |
SSE 스트리밍 타임아웃 (ms) |
| 프로퍼티 | 기본값 | 설명 |
|---|---|---|
spring.ai.vectorstore.milvus.client.host |
localhost ($MILVUS_HOST) |
Milvus 서버 호스트 |
spring.ai.vectorstore.milvus.client.port |
19530 ($MILVUS_PORT) |
Milvus gRPC 포트 |
spring.ai.vectorstore.milvus.client.username |
root ($MILVUS_USERNAME) |
Milvus 접속 계정 |
spring.ai.vectorstore.milvus.client.password |
milvus ($MILVUS_PASSWORD) |
Milvus 패스워드 (운영 시 반드시 변경) |
spring.ai.vectorstore.milvus.collection-name |
vector_store |
Milvus 컬렉션 이름 |
spring.ai.vectorstore.milvus.embedding-dimension |
1024 |
임베딩 차원 (모델과 일치 필수) |
spring.ai.vectorstore.milvus.index-type |
IVF_FLAT |
인덱스 타입 |
spring.ai.vectorstore.milvus.metric-type |
COSINE |
유사도 메트릭 |
임베딩 모델 전환 시 주의:
rag.embedding.type변경 시 Milvus 컬렉션 DROP 후 문서 전체 재수집 필요.
| 값 | 동작 |
|---|---|
milvus (기본값) |
Milvus Standalone에 연결. docker compose up -d 필요. |
memory |
Spring AI SimpleVectorStore (인메모리). Docker 불필요. 재시작 시 초기화. |
| 전략 | 동작 |
|---|---|
semantic (기본값) |
LLM이 구조 여부를 직접 판단. 구조가 있으면 조항·항목 단위로 분리, 없으면 fixed로 자동 대체. 텍스트 20,000자 초과·LLM 실패 시에도 fixed로 대체. |
fixed |
고정 토큰 크기(rag.chunk.size)로 분리 |
비용 주의:
rag.chunk.strategy=semantic이면 수집 요청당 최대 LLM 호출 1회 추가 발생.
rag.query-preprocess.enabled=true(기본값)이면 LlmQueryPreprocessAdapter가 동작하며 LLM 호출 1회가 추가된다.
| 출력 | 설명 | 사용 채널 |
|---|---|---|
keywordQuery |
핵심 명사·동사 중심 쿼리 | BM25 검색 |
vectorQuery |
이상적 답변을 서술한 가상 문서 (HyDE) | Milvus 벡터 검색 |
| 코드 | HTTP | 설명 |
|---|---|---|
R0001 |
500 | 내부 서버 오류 |
R0002 |
400 | 잘못된 요청 |
R0003 |
404 | 리소스 없음 |
R0004 |
405 | 허용되지 않는 HTTP 메서드 |
D0001 |
404 | 존재하지 않는 문서 |
I0001 |
500 | 문서 수집 실패 |
I0002 |
422 | 파일 파싱 실패 |
I0003 |
400 | 수집할 문서 내용 없음 |
I0004 |
503 | 벡터 저장소 저장 실패 |
S0001 |
503 | 검색 서비스 사용 불가 |
L0001 |
503 | AI 답변 생성 실패 |
L0002 |
503 | 임베딩 처리 실패 |