"지금 RAG 구현이 어떻게 돼 있냐"는 질문에 코드를 다시 읽다가 확인한 사실. target-tracking-service(Java)와 threat-intel-ai-service(Python) 둘 다 "RAG"를 하고 있다고 말할 수 있는데, 실제로는 벡터 DB 엔진도 다르고, 같은 Gemini 임베딩 모델을 쓰면서도 출력 차원이 달라서 두 저장소가 물리적으로 호환되지 않는 완전히 독립된 파이프라인이다. 처음부터 이렇게 설계한 게 아니라, 두 서비스가 각자 따로 만들어지면서 자연스럽게 갈라진 결과다.
<table header-row="true"><tr><td></td><td>target-tracking-service(Java)</td><td>threat-intel-ai-service(Python)</td></tr><tr><td>벡터 DB</td><td>pgvector(Postgres)</td><td>Qdrant</td></tr><tr><td>임베딩 모델</td><td>gemini-embedding-001</td><td>gemini-embedding-001(같은 모델)</td></tr><tr><td>임베딩 차원</td><td>1536</td><td>768</td></tr><tr><td>색인 대상</td><td>고정 위협 패턴 10개(손으로 작성)</td><td>업로드 문서 + 실제 탐지 이력(2개 컴렉션)</td></tr><tr><td>색인 시점</td><td>앱 기동 시 1회(idempotent)</td><td>문서는 수동 업로드 시, 이력은 Kafka 이벤트마다</td></tr><tr><td>검색 라우팅</td><td>없음(단일 검색)</td><td>LangGraph가 질문 유형별 컴렉션 분기</td></tr><tr><td>Agentic 요소</td><td>없음</td><td>function-calling으로 도구 호출 여부까지 LLM이 결정</td></tr></table>
ThreatKnowledgeInitializer(ApplicationRunner, @Order(100))가 앱 기동 시 손으로 작성한 위협 패턴 10개를 pgvector에 한 번만 시딩한다. vector_store 테이블에 metadata->>'source' = 'c4i-threat-kb'인 행이 이미 있는지로 idempotent 체크하기 때문에, 재배포/재시작해도 중복 삽입되지 않는다.
검색은 ThreatAnalysisService.analyze()가 담당한다. 현재 표적을 자연어 문장으로 만들고, vectorStore.similaritySearch()(topK=3, 임계값=0.5)로 이 10개 패턴 중 가장 비슷한 걸 찾은 뒤, 그걸 컸텍스트로 Gemini에게 SITREP을 쓰게 한다.
즉 이건 "실시간 탐지 이력 검색"이 아니라 "미리 정의된 교리/패턴과의 매칭"이다. 규칙 기반 위협 등급(calculateRuleBasedThreatLevel)을 보강하는 참고자료 역할에 가깝다.
이쪽은 실제로 동적으로 자라는 데이터를 다룬다.
컴렉션 1: threat_documents — /ingest/doc로 PDF/txt를 업로드하면 chunk_text()(RecursiveCharacterTextSplitter, chunk_size=800/overlap=100)로 청크를 쪼개 뒤 청크마다 임베딩해서 저장한다. 진짜 문서 기반 RAG.
컴렉션 2: target_history — Kafka consumer가 탐지된 표적 이벤트를 자연어로 묘사해서 임베딩·저장한다(upsert_target_event). Java 쪽 고정 패턴과 달리 실제로 관측된 이력을 대상으로 한다. 다만 이 자동 색인은 필터링/쿸다운이 전혀 없어서 ADS-B 피드가 흘려보내는 모든 이벤트마다 Gemini 임베딩을 호출했고, 이게 Gemini 무료 tier 일일 임베딩 한도(1000건)를 실제로 소진시킨 주범이었다 — 지금은 settings.auto_index_enabled(기본 false)로 게이팅되어 있다.
라우팅은 app/graph/router_graph.py의 LangGraph StateGraph가 담당한다:
START → classify → (조건부 분기) → doc_rag ────────→ END
→ pattern_search → assess_threat → END
두 서비스 다 gemini-embedding-001을 쓰지만, Java는 1536차원, Python은 output_dimensionality=768로 호출한다. 같은 모델을 쓴다고 데이터를 공유할 수 있는 게 아니다 — 벡터 크기 자체가 달라서 한쪽 벡터를 다른 쪽 컴렉션에 넣는 것 자체가 불가능하다. 이건 의도된 아키텍처 결정이 아니라, 두 서비스가 서로 다른 시점에 독립적으로 만들어지면서 각자 편한 차원을 골랑기 때문에 생긴 결과다.
지금 당장은 아니라고 판단한다. 두 파이프라인이 실제로 다른 역할을 하고 있기 때문이다 — Java 쪽은 규칙 기반 등급을 보강하는 고정 근거자료고, Python 쪽은 실제 이력/문서를 대상으로 한 실험적 동적 RAG다. 억지로 하나의 벡터 DB로 합치면 "규칙 엔진의 안정적인 참고자료"와 "계속 자라나는 실시간 이력"이라는 서로 다른 요구사항이 충돌할 여지가 있다.
만약 나중에 통합한다면, Qdrant(이미 실시간 이력을 다루는 쪽) 하나로 몰고 Java 서비스가 REST로 조회하는 방향이 pgvector를 Python 쪽에 새로 붙이는 것보다 자연스러울 것이다 — 다만 그 경우 임베딩 차원을 하나로 맞추는 재색인 작업(기존 pgvector 데이터를 768차원으로 다시 임베딩)이 선행되어야 한다.