본문으로 건너뛰기

근거 없으면 답하지 않는다: 서비스 데이터가 AI-Ready 데이터가 되기까지

온톨로지AI 에이전트지식그래프RAGFMS
이승범, 이민식2026년 10월 6일

안녕하세요, UMOS ONE Data & AI 팀에서 데이터 파이프라인과 AI Agent의 데이터 레이어를 만들고 있는 데이터 엔지니어 이승범입니다.

이 글은 MongoDB.local Seoul 2026에서 발표한 「쏟아지는 차량 IoT 데이터를 AI-ready로 만들기까지」의 두 번째 파트를 글로 옮긴 것입니다. 첫 번째 파트에서 김명국 님이 차량의 순간 스냅샷을 MongoDB Time Series로 쌓아 신뢰할 수 있는 정보로 만든 이야기를 했다면, 이 글은 그 정보를 AI Agent가 읽고 추론할 수 있는 형태로 바꾼 이야기입니다.

발표가 차량 관제 서비스 잇Fleet(구 FMS) 세션에 이어진 터라 예시도 잇Fleet 질문이 많습니다. 다만 Data & AI 팀이 다루는 범위는 잇Fleet만이 아닙니다. 창고 운영의 잇Stock(구 WMS), 배차·운송의 잇Truck(구 TMS), 셔틀 호출의 잇Shuttle(구 TAP!)까지 전사 서비스 데이터를 하나의 지식 그래프로 연결하고 있고, "정비 중인 차량이 배정된 운송 건이 있어?"처럼 도메인 경계를 넘는 질문도 같은 파이프라인으로 다룹니다. 이 글의 구조는 그 전체에 적용되는 것이고, 잇Fleet은 설명을 위한 예시로 봐주시면 됩니다. 본문의 코드와 쿼리에 보이는 fms, tms, wms, tap 접두어는 리브랜딩 전 도메인 이름을 그대로 쓰는 온톨로지 네임스페이스입니다.

질문은 하나로 끝나지 않습니다​

"지난 3개월 동안 강남을 주행한 드라이버 중 안전운전 점수가 가장 높은 5명은 누구일까요?"

이 질문 하나만 놓고 보면 어렵지 않습니다. 전용 쿼리나 API를 하나 만들면 됩니다. 문제는 질문이 하나로 끝나지 않는다는 겁니다.

  • 이번 분기 화주 A사 배송 건만 놓고 보면, 정시 도착률이 가장 낮은 차량은 누구일까요?
  • 같은 조건으로 기간만 지난달로 바꾸면 TOP 5 순위는 어떻게 달라질까요?
  • 지난달 냉동 탑차 운행 중 설정 온도를 30분 이상 벗어난 운행은 몇 건일까요?

처음 질문을 서비스 DB에서 풀려면 테이블과 컬렉션 5개를 거쳐야 합니다.

순서테이블 / 컬렉션역할연결 키
1tbl_driver드라이버 마스터driver_id (PK)
2tbl_vehicle_operation운행 기록 — 드라이버와 차량을 잇는다driver_id, vehicle_id (FK)
3tbl_interest_area_access_log차량의 관심구역 진입 로그vehicle_id, area_id (FK), entry_time
4tbl_interest_area관심구역 마스터 — "강남"area_id (PK)
5driver_daily_safety_score일별 안전점수driver_id, standard_at

조인 4번에 기간 집계까지. 사람이 짜도 한참 걸리는 쿼리를, LLM에게 매번 새로 짜게 할 수는 없었습니다.

이 글은 그 질문들에 답하기 위해 서비스 데이터를 AI Agent가 읽을 수 있는 형태로 바꿔 온 과정입니다. 온톨로지로 관계를 스키마에 올리고, 근거가 없으면 답하지 않게 만들고, 문서는 쪼개는 방식부터 다시 정한 이야기입니다.

서비스 DB는 AI가 읽는 형태가 아닙니다​

잘 돌아가는 서비스 DB와 AI가 추론할 수 있는 지식 체계 사이에는 벽이 있습니다. 데이터가 없어서가 아닙니다. 관계가 스키마에 없어서 물어보기 어려운 겁니다.

서비스 중심 구조. 서비스 DB는 그 서비스의 화면과 API를 위해 설계됩니다. 관제 화면이 차량 목록을 빠르게 그려야 하면 차량 테이블이 그에 맞게 정규화되고, 운행 기록을 초 단위로 받아야 하면 MongoDB Time Series 컬렉션이 따로 생깁니다. 백엔드와 프론트엔드가 필요로 하는 조회 패턴이 스키마를 결정하는 거지, "나중에 누가 어떤 질문을 던질지"는 설계 기준이 아닙니다. 그 결과 같은 잇Fleet 안에서도 마스터 데이터는 PostgreSQL에, 차량 신호는 MongoDB에 있는 식으로 저장소와 모델링 방식이 제각각이고, 하나의 질문을 풀려면 성격이 다른 저장소 여러 개를 건너야 합니다. 서비스 안에서는 잘 돌아가지만, 서비스 밖에서 묻기에는 맞지 않는 구조입니다.

도메인 문맥의 부재. FK는 tbl_vehicle_operation.driver_id가 tbl_driver.driver_id와 연결되어 있다는 것까지만 말해 줍니다. "운행은 차량과 드라이버를 잇는다", "안전점수는 드라이버에게 속한다" 같은 의미는 스키마 어디에도 없습니다. 그 의미는 서비스를 만든 사람의 암묵지로만 존재합니다. 사람은 그 암묵지로 쿼리를 짜지만, LLM은 빈자리를 그럴듯하게 채웁니다. 모르는 것을 모른다고 하지 못하는 거죠.

수동 매핑의 한계. 암묵지를 사람이 손으로 옮겨 적으면 됩니다. 하지만 잇Fleet 한 도메인만 테이블 49개, 프로퍼티 345개입니다. 잇Truck은 클래스 110개, 프로퍼티 700개입니다. 구축도 문제지만 유지가 더 문제입니다. 데이터가 늘어나는 속도를 사람이 따라갈 수 없습니다.

그래서 세 가지를 만들었습니다. 관계를 사람이 아니라 파이프라인이 찾게 하고, Agent가 스키마 근거 없이는 답하지 않게 하고, 문서 검색은 측정 가능한 지표로 튜닝했습니다.

신호와 문서는 다른 길로 갑니다​

전체 그림을 먼저 보겠습니다. AI Agent가 다루는 지식은 두 종류입니다. 하나는 차량 신호와 운행·배차·재고 기록 같은 구조화된 서비스 데이터, 다른 하나는 운영 매뉴얼·법령·약관 같은 비정형 문서입니다. 둘은 성격이 달라서 따로 가공하고, Agent 런타임에서 합칩니다.

AI-ready data preparation 아키텍처

경로입력가공저장소
신호·서비스 데이터PostgreSQL, MongoDB Atlas (잇Fleet · 잇Truck · 잇Stock · 잇Shuttle)TBox 생성 (버전별 1회) → ABox 생성 (Databricks 일배치)AWS Neptune 지식 그래프 — 도메인별 named graph + 크로스 도메인 링크 그래프
비정형 문서PDF · MD · JSONLRAG Ingest 서비스 (EKS) — AI 청킹, 문맥 보강, 임베딩MongoDB Atlas — Hybrid Search

Agent 런타임은 발표에서는 네 가지 특징(Plan-and-Execute, Schema Vector Search, Domain Abstention, Unified Memory)으로 요약했지만, 실제 구조는 그보다 복잡합니다. 질문이 들어오면 입력 가드레일을 거쳐 도메인 라우터가 단일 도메인인지 크로스 도메인인지 가르고, 크로스 도메인이면 Plan-and-Execute가 계획을 세워 도메인 에이전트들에게 나눠 줍니다. 각 도메인 에이전트는 LangGraph ReAct 루프로 도구를 고르는데, 도구는 세 층입니다. 사람이 짠 SPARQL 도구(T1), TBox에서 자동 생성한 클래스별 조회 도구(T2), 그리고 둘 다 없을 때 LLM이 SPARQL을 직접 짜는 dynamic query(T4)입니다. 이 글의 Schema Vector Search와 기권은 이 T4 경로의 이야기입니다. 그래프 조회 결과와 문서 검색 결과는 Evidence Fusion에서 합쳐지고, 출력 가드레일을 거쳐 답이 나갑니다.

Agent 런타임 구조

이 Agent 위에 잇Fleet, 잇Truck, 잇Stock, 잇Shuttle 서비스가 올라갑니다. 이 글에서는 왼쪽 경로의 관계 발견, T4 경로의 스키마 검색과 기권, 그리고 오른쪽 경로의 문서 처리 세 가지만 다룹니다.

관계를 사람이 아니라 파이프라인이 찾습니다​

온톨로지는 설계도와 사실로 나뉩니다. TBox(Terminology Box)는 클래스·속성·관계를 정의하는 설계도이고, ABox(Assertion Box)는 그 설계도를 따라 적재된 실제 개체입니다. tbl_driver는 fms:Driver 클래스가 되고, 앞서 예시로 든 차XX 드라이버는 그 인스턴스가 됩니다. TBox는 버전별로 한 번 설계하고, ABox는 Databricks 배치가 매일 증분으로 쌓습니다.

TBox / ABox 파이프라인

TBox를 사람이 그리지 않습니다. DB 프로파일링이 테이블과 컬럼을 읽고, LLM이 테이블→클래스, 컬럼→프로퍼티, FK→ObjectProperty 매핑을 생성합니다. 이 부분과 평가 루프는 이민식 님이 데이터 품질은 스스로 자란다에서 자세히 다뤘으니 여기서는 한 가지만 이야기하겠습니다. FK에 없는 관계는 어디서 오는가.

FK만 따라가면 belongsTo류 관계만 남습니다. "강남을 운전한 드라이버" 같은 질문에 필요한 경로는 FK 어디에도 없습니다. 그래서 Gap Analysis는 스키마가 아니라 비즈니스 질문셋에서 출발합니다.

  1. 후보 제안 — 훈련용 질문 20개씩 묶어 기존 TBox 요약과 함께 LLM에 넘기고, 답하려면 빠진 owl:ObjectProperty를 제안받습니다 (relation_name, subject_class, object_class, confidence)
  2. 커버리지 판정 — 검증용 질문마다 "제안된 관계가 SPARQL 탐색 경로를 제공하는가"를 LLM이 판정합니다
  3. Greedy 선정 — 기여도 내림차순으로 후보를 넣어가며 누적 커버리지 80%에 다다르면 멈춥니다. 새 질문을 커버하지 못하고 confidence도 0.70 미만이면 탈락
  4. 최종 측정 — 테스트용 질문은 선정 과정에 전혀 쓰지 않고 마지막 측정에만 씁니다
# analysis/gap_analysis/relation_optimizer.py
pool_sorted = sorted(
candidates_pool,
key=lambda c: (contribution.get(c["relation_name"], {}).get("total_covered", 0),
c.get("confidence", 0.0)),
reverse=True,
)
for cand in pool_sorted:
if current_coverage >= target_coverage: # 80% 달성 시 나머지 reject
c["status"] = "rejected"; continue
new_covered = {qid for qid, rels in q_covered_by.items()
if cand["relation_name"] in rels and qid not in already_covered}
if new_covered or cand.get("confidence", 0.0) >= 0.70:
already_covered |= new_covered
current_coverage = len(already_covered) / total_q
c["status"] = "kept"

질문셋을 Train/Val/Test로 나눈 데는 한 번 데인 경험이 있습니다. 초기에는 검증용 질문을 후보 생성에도 썼는데, 그렇게 고른 관계는 테스트셋에서 일반화되지 않았습니다. 관계를 질문에 맞춰 만들면 그 질문에만 답하는 온톨로지가 됩니다. 테스트셋 97문항 기준으로 답변 가능 질문은 확장 전 12건에서 확장 후 19건이 됐습니다. 많지 않아 보이지만, 사람이 한 줄도 안 그린 관계 7개가 생긴 겁니다.

조인 4번이 순회 1번이 됩니다​

관계가 스키마에 올라가면 질문의 조건이 곧 온톨로지 속성이 됩니다. 처음 질문을 다시 보겠습니다.

질문 조건온톨로지 속성
"지난 3개월간"hasStandardAt ≥ today − 90d
"강남을 운전한"hasVisitedArea → "강남"
"점수 상위 5명"hasSafeDriveScore TOP 5

관계 3개를 타는 한 번의 순회

조인 4번이 관계 3개를 타는 한 번의 순회가 됩니다. (위 관계명은 설명을 위한 예시입니다. 실제 TBox에서는 지역 관계가 별도 클래스로 표현됩니다.)

도메인을 넘는 질문도 같은 방식입니다. "정비 중인 차량이 배정된 운송 건이 있어?"는 잇Fleet 그래프의 fms:Vehicle과 잇Truck 그래프의 tms:Dispatch를 차량번호로 잇는 링크 그래프를 타고 한 번에 내려갑니다. 도메인별 named graph는 따로 두고, 그 사이의 대응 관계만 링크 그래프에 미리 계산해 둡니다.

더 중요한 건 어디서 왔는지가 남는다는 점입니다. 그래프의 DriverDailySafetyScore 노드 하나는 Part 1의 시계열에서 시작합니다.

  1. 속도·가속 시계열 (MongoDB Time Series)
  2. 급가속·급제동 이벤트 추출
  3. 일별 안전점수 집계 (driver_daily_safety_score)
  4. 그래프 노드 fms:DriverDailySafetyScore — 2026-08-24, 96점

실제 쿼리는 이렇게 생겼습니다. 드라이버별 최신 점수를 기간 안에서 골라 상위 N을 뽑는 SPARQL입니다.

SELECT DISTINCT ?driver_id ?driver_name ?score ?standard_at
WHERE {
GRAPH <http://umosone.ai/graph/fms> {
{ SELECT ?driver_id (MAX(?t) AS ?standard_at) WHERE {
?x a fms:DriverDailySafetyScore ;
fms:hasDriverId ?driver_id ;
fms:hasStandardAt ?t .
FILTER(STR(?t) >= "2026-07-06")
} GROUP BY ?driver_id }
?s a fms:DriverDailySafetyScore ;
fms:hasDriverId ?driver_id ;
fms:hasStandardAt ?standard_at ;
fms:hasSafeDriveScore ?score .
OPTIONAL { ?drv a fms:Driver ; fms:hasDriverId ?driver_id ; fms:hasName ?driver_name . }
}
}
ORDER BY DESC(?score) LIMIT 5

질문이 바뀌어도 구조는 같습니다. 기간을 바꾸면 FILTER가, 정렬 기준을 바꾸면 ORDER BY가 바뀝니다. 이 쿼리를 LLM이 짜게 하는 게 다음 이야기입니다.

근거가 없으면 대답하지 않게 만듭니다​

LLM에게 SPARQL을 짜게 하면 가장 흔한 사고는 존재하지 않는 속성을 지어내는 겁니다. fms:hasSafeDriveScore가 있으면 fms:hasAccidentCount도 있을 거라고 짐작하고, 쿼리는 돌아가지만 결과는 0건이고, Agent는 "사고 이력이 없습니다"라고 답합니다. 틀린 답이 맞는 답처럼 나갑니다.

전체 TBox를 프롬프트에 넣으면 될까요. 잇Fleet만 클래스 49개, 프로퍼티 345개입니다. 잇Truck은 프로퍼티가 700개입니다. 전부 넣으면 토큰도 문제지만, LLM이 그 안에서 맞는 걸 고르지 못합니다. 그래서 질문과 관련된 서브스키마만 찾아서 주고, 그 밖은 쓰지 못하게 했습니다.

Schema Vector Search와 Abstention Guardrail

준비 (오프라인 1회). TBox를 클래스 단위·프로퍼티 단위로 썰어 문서로 만듭니다. 클래스 문서는 도메인 | 클래스명 | 설명 | 프로퍼티 목록, 프로퍼티 문서는 도메인 | 클래스 | 프로퍼티 | range | 설명 | enum 값. 설명은 TTL의 rdfs:label@ko와 rdfs:comment에서 가져옵니다. 잇Fleet은 394건, 잇Truck은 810건이 MongoDB Atlas schema_vectors 컬렉션에 도메인 필터와 함께 들어갑니다.

# multi_agents/dynamic_query/guard.py — SchemaVectorStore
pipeline = [
{"$vectorSearch": {
"index": "vector_index", "path": "embedding",
"queryVector": query_embedding,
"numCandidates": max(40, top_k * 10), "limit": top_k,
"filter": {"domain": domain},
}},
{"$project": {"text": 1, "metadata": 1, "score": {"$meta": "vectorSearchScore"}}},
]

운영 (런타임). 사용자 질문이 들어오면 SPARQL을 짜기 전에 서브스키마를 벡터 검색합니다(top-k 24). 회수된 클래스·프로퍼티·enum을 허용 집합으로 묶어 프롬프트에 주입합니다. Named graph도 같이 고정합니다.

### [IMMUTABLE ONTOLOGY CONSTRAINTS]
당신은 반드시 아래에 정의된 클래스와 속성만 사용해 SPARQL을 생성해야 합니다.
정의되지 않은 속성/클래스 발명은 금지됩니다.
- Required PREFIX: fms: <http://umosone.ai/ontology/fms#>
- Required GRAPH: <http://umosone.ai/graph/fms>
1. Classes: [fms:Driver, fms:DriverDailySafetyScore, ...]
2. Properties:
- fms:hasSafeDriveScore
- fms:hasStandardAt
...
### [RULE]
위 리스트에 없는 속성을 사용해야만 답변이 가능하면, 쿼리를 생성하지 말고
정확히 "데이터 모델에 해당 정보가 정의되어 있지 않습니다" 라고만 답변하십시오.

생성된 SPARQL은 실행 전에 허용 집합과 대조합니다. 주석·IRI·문자열을 걸러내고 prefix:term 형태를 전부 뽑아 허용 집합 밖의 항이 있으면 차단합니다.

# multi_agents/dynamic_query/guard.py — OntologyTermValidator
invalid = [t for t in used_terms
if t not in allowed_classes and t not in allowed_properties
and not t.startswith(("rdf:", "rdfs:", "xsd:", "owl:", "skos:"))]
if invalid:
return {"is_valid": False, "invalid_terms": invalid,
"message": f"허용되지 않은 ontology term이 있습니다: {invalid}. "
f"허용 집합 내에서만 다시 작성하세요."}

차단되면 그 메시지를 피드백으로 붙여 재생성합니다(최대 2회). 재시도를 다 쓰면 쿼리를 실행하지 않고 거절합니다. ONTOLOGY_GUARD_MODE를 shadow로 두면 차단 없이 로그만 남기므로, 운영 트래픽에서 얼마나 걸리는지 먼저 보고 enforce로 올립니다.

기권은 두 계층입니다. 위에서 본 건 "스키마 근거가 없을 때"의 기권입니다. 그 위에 "내 도메인이 아닐 때"의 기권이 하나 더 있습니다. 잇Fleet 에이전트에게 창고 재고를 물으면 도구를 부르지 않고 [ABSTAIN] 사유: ...로 답합니다. 프롬프트에 한 줄을 꼭 넣었습니다. "도구를 호출해서 빈 결과가 나오는 것은 기권이 아닙니다. 데이터가 없는 것과 도메인이 다른 것을 구분하세요." 이 한 줄이 없으면 LLM은 0건 결과를 기권으로 포장합니다.

환각을 막는 가장 확실한 방법은 모델을 더 똑똑하게 만드는 게 아니라, 모델이 쓸 수 있는 어휘를 제한하고 그 밖에서는 입을 닫게 하는 것이었습니다.

문서는 쪼개는 방식이 성능을 정합니다​

지식 그래프가 "어떤 드라이버가 몇 점인가"를 답한다면, 문서 검색은 "안전점수는 어떻게 계산하나", "차량 반납 시 점검 항목은 무엇인가"를 답합니다. 운영 매뉴얼, 법령, 약관이 PDF·MD·JSONL로 들어옵니다. 도메인마다 따로 만들던 ingest 파이프라인을 rag-document-ingestor 하나로 합쳤고, 그 과정에서 세 가지를 정했습니다.

문서 처리 파이프라인

1. 청킹 전략을 문서마다 LLM이 고릅니다. 법령은 조항 단위로, FAQ는 문답 단위로, 회의록은 일정 길이로 나누어야 합니다. 하나의 규칙으로는 안 됩니다. 그래서 업로드 시점에 정규식으로 구조 시그널을 뽑고(헤더 수, 제N조 패턴, 표 유무, Q/A 패턴), 긴 문서는 3구간 샘플링해 LLM(claude-haiku-4-5, temperature 0)에게 넣습니다. 프롬프트에는 4단계 결정 프레임워크가 들어 있습니다.

Step 1 — Atomicity: char_count < 800 AND qa_pattern_count ≥ 1 → by_document
Step 2 — Structure: (header_count + section_pattern_count) ≥ 3
AND avg_para_len < 2,000 → by_section
Step 3 — Transcript: qa == 0 AND list < 3 AND header == 0
AND char_count > 5,000 → flat
Step 4 — Default → parent_child

LLM은 {strategy, confidence, reasoning, signals_detected}를 JSON으로 돌려주고, 그 전략으로 ingest합니다. LLM이 잘못 골랐을 때를 대비해 코드 레벨 방어도 둡니다. by_document가 8,191 토큰을 넘으면 parent_child로 강제 전환하고, by_section에서 섹션 하나가 8,000 토큰을 넘으면 문단 병합 → 슬라이딩 윈도우로 2단 재분할합니다.

2. 청크에 문맥을 붙입니다 (Contextual Retrieval). "제3항의 경우 전항을 준용한다" 같은 청크는 떼어내면 무의미합니다. 그래서 임베딩 전에 문서 앞부분과 청크를 같이 LLM에 넣어 "이 청크가 문서 어디에 있고 무슨 내용인지" 한 문장을 받아 청크 앞에 붙입니다.

# app/domain/contextual.py
_PROMPT_TEMPLATE = (
"다음 문서에서 아래 청크의 위치와 의미를 간략히 설명하는 컨텍스트를 한 문장으로 작성하세요.\n\n"
"<document>\n{document}\n</document>\n\n"
"<chunk>\n{chunk}\n</chunk>\n\n"
"컨텍스트:"
)
# ingest.py: cd["text"] = f"{ctx}\n\n{cd['text']}" → 임베딩

3. 키워드와 벡터를 가중 합산하고 리랭크합니다 (Hybrid Search). 벡터 검색만 쓰면 "ADAS 경고 코드 E-42" 같은 고유명사를 놓칩니다. 그래서 MongoDB Atlas의 $vectorSearch와 $search(nori 한국어 형태소 분석기)를 병렬로 돌리고, 점수를 min-max 정규화한 뒤 가중 합산합니다. 상위 10건만 리랭커에 넘겨 5건을 뽑습니다.

# app/domain/fusion.py
WEIGHTED_ALPHA = 0.8 # keyword(BM25) 가중치

def weighted_fuse(vector_docs, keyword_docs, alpha=WEIGHTED_ALPHA):
v_norm = dict(zip(ids(vector_docs), _minmax(scores(vector_docs))))
k_norm = dict(zip(ids(keyword_docs), _minmax(scores(keyword_docs))))
fused = [{**doc, "score": alpha * k_norm.get(cid, 0.0)
+ (1 - alpha) * v_norm.get(cid, 0.0)}
for cid, doc in union(vector_docs, keyword_docs).items()]
return sorted(fused, key=lambda d: d["score"], reverse=True)

키워드 가중치가 0.8인 게 의외일 수 있는데, 매뉴얼과 참고 문서에는 "사고", "배차", "입고"처럼 도메인 고유 명사가 많아 키워드 검색 성능이 좋습니다. 실제로 keyword 단독이 vector 단독보다 recall@5가 8.4pt 높았습니다. 벡터는 키워드가 놓치는 표현 차이를 메우는 역할입니다.

여기서 삽질을 하나 고백하면, 처음 Hybrid를 붙였을 때 recall이 keyword 단독보다 떨어졌습니다 (0.949 → 0.775). fusion이 문제인 줄 알고 RRF도 써보고 가중치도 바꿨는데, 진짜 원인은 점수의 성격이 달랐다는 데 있었습니다. 벡터 검색의 cosine 유사도는 0에서 1 사이의 절대적인 척도라 min_score=0.70 같은 임계값이 의미가 있습니다. 반면 리랭커 점수는 그 요청에 들어온 후보들 사이의 상대 평가입니다. 정답 청크라도 0.3~0.6이 흔하고, 후보 구성에 따라 같은 청크의 점수가 달라집니다. 이 점수에 절대 임계값을 걸려면 아주 낮은 값을 써야 하고, 사실상 걸지 않는 게 맞습니다. cosine용 임계값을 리랭커 출력에 그대로 적용한 탓에 정답을 잘라내고 있었던 겁니다. 임계값을 단계별로 분리하니 0.957로 돌아왔습니다.

숫자로 확인합니다​

검색기 평가는 LLM-as-judge를 빼고 결정론적 세 지표로만 합니다. recall@5, MRR, latency p95. 같은 입력에 같은 수가 나와야 설정 하나 바꾼 효과를 믿을 수 있습니다.

발표에서는 recall@5 0.978, MRR 0.873, p95 1,053ms를 보여드렸습니다. 이 수치는 8월에 179케이스 골든셋(v2.1)으로 로컬에서 잰 값입니다. 그 뒤 골든셋을 다시 봤더니 질문과 문서의 어휘가 겹쳐 점수가 낙관적으로 나오고 있었습니다. 패러프레이즈 변형을 더해 498케이스(v2.2)로 다시 만들었고, 측정 환경도 로컬이 아니라 int 환경으로 옮겼습니다. 아래 표는 그 기준입니다. 발표보다 수치가 낮은 건 검색기가 나빠진 게 아니라 잣대가 더 엄격해진 겁니다.

검색 프리셋 비교 (golden set v2.2, 498케이스, int 환경, 2026-09-18)

구성recall@5MRRp50 (ms)p95 (ms)
hybrid-weighted + cohere-rerank-v3.5 (titan-embed-v2)0.9060.785511826
hybrid-weighted + voyage-rerank-3 (voyage-4-large)0.9260.8087281,184

참고로 같은 골든셋으로 vector 단독(titan)을 로컬에서 재면 recall@5 0.845, MRR 0.738입니다. 하이브리드와 리랭커가 8pt를 올린 셈입니다.

발표 당시에는 Cohere Rerank v3.5를 썼습니다. 9월 말에 임베딩을 voyage-4-large, 리랭커를 voyage-rerank-3로 바꿨습니다. recall@5가 2pt 올랐고, 잇Stock 도메인에서는 벡터 단독 recall이 67.6%에서 86.9%로 뛰었습니다. 비용은 검색 1건당 $0.0020에서 $0.0003으로 약 87% 줄었는데, 임베딩이 아니라 리랭커가 건수 과금에서 토큰 과금으로 바뀐 덕입니다.

품질과 latency 사이에는 트레이드오프가 있었습니다. p95가 826ms에서 1,184ms로 358ms 늘었습니다. 구간별로 뜯어보면 임베딩이 +153ms, 리랭크가 +194ms이고 나머지(Atlas 검색, fusion, 네트워크)는 거의 그대로입니다.

  • 임베딩 +153ms는 구조적입니다. titan은 서울 리전 Bedrock에서 응답하지만, voyage 계열은 LLM Gateway가 미국 오리진(ai.mongodb.com)으로 라우팅합니다. 무인증 TTFB만 약 245ms 대 80ms 차이라, 9번 측정에서 voyage 임베딩이 200ms 아래로 내려온 적이 없습니다.
  • 리랭크 +194ms는 지리 문제가 아닙니다. 같은 voyage 검색 경로에서 리랭커만 서울의 cohere로 바꿔 봐도 716ms로 느린 그대로였습니다. 남은 설명은 voyage 임베딩이 고르는 top-10 후보 집합이 평균적으로 더 길다는 것인데, 평가 로그에 후보 길이가 남지 않아 확정하지는 못했습니다.
  • 로컬에서는 voyage가 더 빨라 보였는데, 그건 로컬에서 cohere의 꼬리 지연이 비정상적으로 길었기 때문이었습니다. int에서 cohere 꼬리가 정상으로 돌아오자 착시가 사라졌습니다.

그래서 latency보다 검색 품질을 우선하는 쪽으로 결정했습니다. 리랭커 후보를 10건에서 5건으로 줄이면 빨라지지만 recall이 94.4%에서 90.2%로 떨어져 10건을 유지했습니다. p95 1초 초과는 모니터링 트리거를 걸어 두고 수용했습니다.

다만 이 latency가 끝은 아닙니다. 지금 구성은 앱이 임베딩과 리랭크를 LLM Gateway를 거쳐 따로 호출하는 2-hop 구조입니다. MongoDB Atlas에는 저장 시점에 자동으로 임베딩을 생성하는 Automated Embedding과, 검색 파이프라인 안에서 리랭크까지 처리하는 Native Reranking이 있는데, 전환 당시에는 저희 클러스터가 storage autoscale 구성이 아니라 Automated Embedding을 켤 수 없었고, Native Reranking은 GA 전이라 도입을 미뤘습니다. 그 사이 Native Reranking이 GA되어 지금은 적용을 검토하고 있습니다. 두 기능을 적용하면 임베딩과 리랭크 호출이 Atlas 안으로 들어가 Gateway 홉과 별도 호출이 사라지므로, 지금 늘어난 지연의 상당 부분을 되찾을 수 있을 것으로 기대하고 있습니다.

답변 품질 비교 (잇Stock 챗봇 Q&A 718문항, LLM-as-judge gpt-4o-mini, 5점 만점)

시스템평균 점수
본 시스템 (Hybrid RAG)3.338
GraphRAG (basic)3.208
RAG-Anything3.192

같은 문서셋으로 오픈소스 GraphRAG와 RAG-Anything을 돌려 비교했습니다. 발표에서는 운영 데이터 의존 문항 49건을 뺀 669문항 기준(3.460 / 3.322 / 3.298)을 보여드렸는데, 여기서는 전체 718문항 기준으로 적었습니다. 순위는 같습니다. 차이가 크지는 않지만, 문서 구조에 맞춘 청킹과 키워드 가중 하이브리드가 그래프 기반 RAG보다 사내 문서에는 더 맞았습니다.

마치며​

처음 질문으로 돌아가겠습니다. "지난 3개월 강남을 주행한 드라이버 중 안전점수 상위 5명"에 답하려면 세 가지가 필요했습니다.

  1. 관계가 스키마에 있어야 합니다. FK만으로는 부족해서, 비즈니스 질문셋에서 관계를 찾고 Greedy로 골라 TBox에 올렸습니다. 사람은 후보를 제안하지 않고 판정 기준을 정합니다.
  2. 모델은 아는 것만 말해야 합니다. 서브스키마를 벡터 검색해 허용 집합을 만들고, 그 밖은 차단하고, 근거가 없으면 기권합니다. "결과 0건"과 "기권"은 다릅니다.
  3. 문서는 구조에 맞게 쪼개고, 점수는 같은 자로 재야 합니다. 청킹 전략을 문서마다 고르고, 키워드와 벡터를 가중 합산하고, 절대 척도와 상대 척도에 같은 임계값을 걸지 않습니다.

이 일을 하면서 가장 크게 느낀 건, AI Agent의 성패가 모델 성능에서 갈리지 않는다는 겁니다. 데이터를 지식화하고 제약을 제공하는 데이터 레이어의 정밀도가 정합니다. 모델은 바꾸면 됩니다. 실제로 리랭커를 바꿨고, 임베딩도 바꿨습니다. 검증된 데이터 파이프라인이 있으면 그런 교체는 설정값 세 개의 문제가 됩니다.

발표 영상은 MongoDB.local Seoul 2026 세션에서 보실 수 있습니다. Part 1의 차량 시계열 이야기까지 한 번에 이어집니다.