왜 별도 Python 서비스인가

target-tracking-service(Spring Boot)는 이미 Spring AI + pgvector로 RAG 위협 분석을 수행한다. 그런데 그 RAG는 다음 한 가지 형태로 고정되어 있다.

실시간으로 들어온 표적 1건 → 사전에 시딩해둔 고정 10개 위협 패턴과 코사인 유사도 비교 → SITREP 생성

이 서비스가 채우는 빈틀은 두 가지다.

  1. 비정형 문서: 위협 패턴은 코드로 미리 정의된 10개뿐이고, 실제 교리 문서·정보 리포트 같은 텍스트 덩어리를 검색 대상으로 삼지 못한다.
  2. 실제 이력: 비교 대상이 고정 지식베이스일 뿐, 지금까지 실제로 탐지된 표적들의 이력과는 비교하지 않는다.

Java 진영에서 억지로 확장하기보다, Python/LangChain 생태계가 강점을 가진 비정형 문서 처리·에이전트 라우팅을 별도 마이크로서비스로 분리하는 쪽이 자연스럽다.

LangGraph 라우팅

/chat은 질문을 그대로 LLM에 넣지 않고, 먼저 두 갈래로 분류한다.

START
  │
  ▼
classify (Gemini 분류 프롬프트, 실패 시 키워드 휴리스틱 폴백)
  │
  ├─ doc_rag ────────▶ Qdrant[threat_documents] 검색
  │                         │
  └─ pattern_search ─▶ Qdrant[target_history] 검색
                            │
                            ▼
                    context + sources 반환
                            │
                            ▼
              (그래프 밖) Gemini 스트리밍 답변 생성

분류를 그래프 밖 단일 LLM 호출로 처리하지 않고 별도 노드로 분리한 이유는, 라우팅 로직(임베딩 대상 컴렉션 선택)과 생성 로직(최종 답변 스트리밍)의 책임을 나눠서 나중에 노드를 더 추가하기 쉽게 하기 위함이다.

최종 답변 생성은 그래프 안에 넣지 않았다. LangGraph 노드 단위 스트리밍은 SSE와 엮기기 번거로워서, "그래프는 검색까지만 담당하고, 스트리밍 생성은 라우터가 받은 컸텍스트로 직접 수행"하는 방식을 택했다.

Qdrant 컴렉션

<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 참고.

Kafka 연동

target-tracking-service가 생산하는 동일한 target-tracking 토픽을 구독하되, consumer group을 threat-intel-ai-service로 분리했다. Kafka는 토픽 하나를 여러 consumer group에 독립적으로 팬아웃하므로, Java 서비스의 target-tracking-group과 오프셋을 공유하지 않고 각자 전체 스트림을 받는다.

Graceful Degradation