qdrant_store.py — 공용 클라이언트와 컴렉션 초기화

_client: AsyncQdrantClient | None = None

def get_client() -> AsyncQdrantClient:
    global _client
    if _client is None:
        _client = AsyncQdrantClient(host=settings.qdrant_host, port=settings.qdrant_port)
    return _client

동기 QdrantClient가 아니라 AsyncQdrantClient를 쓴 이유는 이 서비스 전체가 FastAPI(비동기) 위에서 돈다는 것과 직결된다. 동기 클라이언트를 async 핸들러 안에서 그대로 호출하면 그 요청을 처리하는 동안 이벤트 루프 전체가 블로킹된다.

async def ensure_collections() -> None:
    client = get_client()
    for collection in (settings.qdrant_doc_collection, settings.qdrant_pattern_collection):
        exists = await client.collection_exists(collection)
        if not exists:
            await client.create_collection(
                collection_name=collection,
                vectors_config=qmodels.VectorParams(size=settings.embedding_dimension, distance=qmodels.Distance.COSINE),
            )

main.py의 lifespan에서 앱 시작 시 한 번 호출된다. 실제로 검증된 동작: Qdrant가 떠있지 않으면 이 함수가 예외를 던지고 main.py의 lifespan이 실패해서 앱 자체가 기동하지 않는다. 이건 의도한 설계다 — Gemini 키와 달리 Qdrant는 이 서비스의 핵심 저장소라 "AI 기능만 ꮤ진 채 서비스는 살아있는" 상태가 성립하지 않는다.

chunking.py — 텍스트 분할

def chunk_text(text: str) -> list[str]:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=settings.chunk_size,
        chunk_overlap=settings.chunk_overlap,
        separators=["\n\n", "\n", ". ", " ", ""],
    )
    return [c for c in splitter.split_text(text) if c.strip()]

langchain-text-splitters의 RecursiveCharacterTextSplitter를 그대로 가져다 쓴 이유는 "문단 → 줄바꿈 → 문장 → 단어" 순으로 구분자를 시도하며 의미 단위를 최대한 안 끓는 로직을 이미 갖춰서다.

doc_store.py — 문서 RAG용 컴렉션(threat_documents)

청크마다 별도 포인트 ID(uuid4)를 발급하면서도 doc_id를 payload에 같이 저장하는 이유는, "이 청크가 어느 문서에서 왔는지"를 나중에 필터링/삭제할 수 있게 하기 위해서다. 임베딩을 청크마다 순차적으로 호출하고 있어(for 루프 안에서 embed_text 호출) 배치 API가 아니라서 청크가 많은 문서를 올리면 이 부분이 요청 처리 시간의 대부분을 차지한다 — 대량 문서를 다루면 첫 번째 최적화 지점.

pattern_store.py — 이력 탐지용 컴렉션(target_history)

def describe_event(event: TargetEvent) -> str:
    return (f"표적ID={event.targetId}, 유형={event.targetType}, "
            f"위도={event.latitude}, 경도={event.longitude}, "
            f"고도={event.altitude}m, 속도={event.speed}km/h, 상태={event.status}")

정형 데이터를 굳이 자연어 문장으로 바꿔서 임베딩하는 이유 — 벡터 임베딩 모델은 자연어 문장에서 의미적 유사도를 뿑도록 학습되어 있어서, 숫자 배열을 직접 벡터화하는 것보다 "고도 45m, 속도 280km/h로 접근"라는 문장을 임베딩하는 쪽이 "비슷한 상황"을 더 잘 포착한다. target-tracking-service의 ThreatAnalysisService RAG 1단계와 동일한 아이디어를 그대로 가져온 것.

observed_at을 UTC로 저장한 건 나중에 시간 범위 필터나 정렬에 쓰일 걸 감안해서다 — Kafka 이벤트 자체에는 타임스탬프 필드가 없어서(TargetEvent.java에 없음), 수신 시각을 대리 타임스탬프로 기록한다. 정확히는 "탐지 시각"이 아니라 "이 서비스가 색인한 시각"이라는 점은 한계로 남아있다.