target-tracking-service(Spring Boot)는 이미 Spring AI + pgvector로 RAG 위협 분석을 수행한다. 그런데 그 RAG는 다음 한 가지 형태로 고정되어 있다.
실시간으로 들어온 표적 1건 → 사전에 시딩해둔 고정 10개 위협 패턴과 코사인 유사도 비교 → SITREP 생성
이 서비스가 채우는 빈틀은 두 가지다.
Java 진영에서 억지로 확장하기보다, Python/LangChain 생태계가 강점을 가진 비정형 문서 처리·에이전트 라우팅을 별도 마이크로서비스로 분리하는 쪽이 자연스럽다.
/chat은 질문을 그대로 LLM에 넣지 않고, 먼저 두 갈래로 분류한다.
START
│
▼
classify (Gemini 분류 프롬프트, 실패 시 키워드 휴리스틱 폴백)
│
├─ doc_rag ────────▶ Qdrant[threat_documents] 검색
│ │
└─ pattern_search ─▶ Qdrant[target_history] 검색
│
▼
context + sources 반환
│
▼
(그래프 밖) Gemini 스트리밍 답변 생성
분류를 그래프 밖 단일 LLM 호출로 처리하지 않고 별도 노드로 분리한 이유는, 라우팅 로직(임베딩 대상 컴렉션 선택)과 생성 로직(최종 답변 스트리밍)의 책임을 나눠서 나중에 노드를 더 추가하기 쉽게 하기 위함이다.
최종 답변 생성은 그래프 안에 넣지 않았다. LangGraph 노드 단위 스트리밍은 SSE와 엮기기 번거로워서, "그래프는 검색까지만 담당하고, 스트리밍 생성은 라우터가 받은 컸텍스트로 직접 수행"하는 방식을 택했다.
<table header-row="true"><tr><td>컴렉션</td><td>내용</td><td>적재 경로</td></tr><tr><td>threat_documents</td><td>업로드된 위협 인텔리전스 문서 청크</td><td>POST /ingest/doc</td></tr><tr><td>target_history</td><td>Kafka target-tracking 토픽으로 들어온 모든 표적 이벤트의 자연어 설명</td><td>Kafka consumer(백그라운드)</td></tr></table>
두 컴렉션 모두 Gemini gemini-embedding-001을 768차원으로 임베딩한다(output_dimensionality=768).
두 검색(doc_rag, pattern_search) 모두 벡터 유사도만으로 top_k(기본 3개)를 바로 확정하지 않고, rerank_candidates(기본 10개)만큼 넓게 붑은 뒤 Cohere Rerank(rerank-v3.5)로 재정렬해서 top_k로 좋힌다(app/rag/reranker.py). 자세한 배경은 docs/concepts/13-reranker.md 참고.
target-tracking-service가 생산하는 동일한 target-tracking 토픽을 구독하되, consumer group을 threat-intel-ai-service로 분리했다. Kafka는 토픽 하나를 여러 consumer group에 독립적으로 팬아웃하므로, Java 서비스의 target-tracking-group과 오프셋을 공유하지 않고 각자 전체 스트림을 받는다.