_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 기능만 ꮤ진 채 서비스는 살아있는" 상태가 성립하지 않는다.
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를 그대로 가져다 쓴 이유는 "문단 → 줄바꿈 → 문장 → 단어" 순으로 구분자를 시도하며 의미 단위를 최대한 안 끓는 로직을 이미 갖춰서다.
청크마다 별도 포인트 ID(uuid4)를 발급하면서도 doc_id를 payload에 같이 저장하는 이유는, "이 청크가 어느 문서에서 왔는지"를 나중에 필터링/삭제할 수 있게 하기 위해서다. 임베딩을 청크마다 순차적으로 호출하고 있어(for 루프 안에서 embed_text 호출) 배치 API가 아니라서 청크가 많은 문서를 올리면 이 부분이 요청 처리 시간의 대부분을 차지한다 — 대량 문서를 다루면 첫 번째 최적화 지점.
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에 없음), 수신 시각을 대리 타임스탬프로 기록한다. 정확히는 "탐지 시각"이 아니라 "이 서비스가 색인한 시각"이라는 점은 한계로 남아있다.