AI를 제품에 넣겠다고 하면 대부분 두 가지 중 하나를 떠올린다. GPT를 fine-tuning하거나, RAG를 쓰거나. 우리는 RAG를 택했다. 이유가 있다.
REINDEERS는 2015년 IMARKET Thailand 설립 이후 약 11년간 동남아시아에서 B2B 무역을 해왔다. 25,000건 이상의 거래, 4,300개 이상의 파트너, 4개국의 규제 데이터. 이 데이터가 우리의 경쟁력이다. 문제는 이 데이터를 AI가 활용할 수 있는 형태로 만드는 과정이 생각보다 훨씬 복잡했다는 거다. 이 글은 그 과정에 대한 기록이다.
한 가지를 먼저 밝히고 시작한다. 이 RAG 지식 베이스는 단순히 "챗봇이 무역 지식을 참조하는 데이터 창고"가 아니다. REINDEERS와 POP, DVRP는 지금 AI로 전환되는 구조 위에 올라서 있다. 사람은 전략과 방향을 결정하고, 실제 업무는 조직도 안에 등록된 AI Agent가 수행한다. 이 Agent들 , 구매 Agent, 물류 Agent, 통관 Agent, 재무 Agent , 이 사람 대신 판단을 내릴 때 "왜 이렇게 결정했는가"를 반드시 설명할 수 있어야 한다. 그 설명의 출처가 바로 이 RAG 지식 베이스다. 11년의 무역 데이터는 Agent가 환각 없이 결정을 내릴 수 있는 유일한 근거다.
RAG를 선택한 이유
먼저 RAG(Retrieval-Augmented Generation)가 뭔지를 간단히 정리하자. LLM에 질문을 던질 때, 질문만 보내는 게 아니라 관련 데이터를 함께 보내는 방식이다. "태국산 베어링 수입 단가가 얼마야?"라고 물으면, 우리 데이터베이스에서 관련 거래 이력을 찾아서 LLM에 컨텍스트로 넘긴다. LLM은 그 컨텍스트를 바탕으로 답을 생성한다.
Fine-tuning을 선택하지 않은 이유는 명확하다.
무역 데이터는 계속 바뀐다. 베어링 단가는 분기마다 변동한다. 태국 FDA 규제는 수시로 개정된다. 선사 운임은 매주 다르다. Fine-tuning은 모델을 다시 학습시켜야 하는데, 이 주기가 데이터 변동 주기를 따라갈 수 없다. 학습에 며칠이 걸리고, 비용도 상당하다. RAG는 데이터만 갱신하면 다음 질문부터 바로 반영된다.
고객 데이터의 프라이버시. Fine-tuning을 하면 고객의 거래 데이터가 모델 가중치에 녹아든다. 어떤 고객의 데이터가 다른 고객의 질의 응답에 영향을 줄 수 있다는 뜻이다. RAG에서는 검색 단계에서 권한 필터링을 걸 수 있다. A 고객이 질문하면 A 고객의 데이터만 검색된다. 테넌트 격리가 자연스럽게 된다.
환각(hallucination) 통제. 무역에서 AI가 틀린 답을 하면 심각하다. "이 HS코드로 수입 가능합니다"가 틀리면 통관이 막히고 컨테이너가 항구에서 멈춘다. RAG는 답변의 근거가 되는 원본 데이터를 참조할 수 있게 해준다. "이 답은 2025년 3월 거래 TXN-2025-03-0042를 기반으로 합니다"라고 출처를 명시할 수 있다. Fine-tuning된 모델은 이게 안 된다.
지식 베이스의 다섯 가지 층
REINDEERS의 RAG 지식 베이스는 다섯 개 층으로 구성된다. 각 층은 데이터의 성격과 갱신 주기가 다르다.
Layer 1: 거래 이력. 25,000건 이상의 실제 거래 데이터다. 어떤 제품이, 어디서 어디로, 어떤 가격에, 어떤 조건으로 거래되었는지. 이 데이터가 가격 비교, 견적 검증, 공급사 추천의 핵심 기반이다. 단순히 "가격"만 있는 게 아니라, 계약 조건(FOB, CIF, EXW), 결제 조건(T/T, L/C), 리드타임, 품질 이슈 이력까지 포함한다.
Layer 2: 무역 서류. Document AI를 통해 처리된 인보이스, PO, 통관 신고서, 원산지 증명서 등이다. 이 서류들에서 추출된 구조화 데이터 , 품목명, HS코드, 단가, 수량, 세율 , 가 지식 베이스에 들어간다. 원본 PDF도 보관되지만, 검색 대상은 추출된 텍스트와 메타데이터다.
Layer 3: 규제 데이터. HS코드별 인증 요건, 수입 규제, 관세율 등이다. 태국 FDA, TISI(태국 공업규격원), 한국 KC 인증 등의 데이터를 크롤링해서 유지한다. 이 층의 데이터는 규제 변경에 따라 갱신되며, 현재 일 단위로 크롤링한다.
Layer 4: 운영 데이터. 물류 경로별 운송 소요일, 포워더별 서비스 평가, 항구별 혼잡도, 계절별 운임 변동 패턴 등이다. 현재 DVRP가 베타 운영 중이며, DVRP 베타 데이터가 쌓이면 배송 실적 데이터와 포워딩 비딩에서 수집된 견적 데이터를 이 층에 통합할 예정이다.
Layer 5: 커뮤니케이션 패턴. 구축 계획 중인 층으로, 국가별, 산업별 거래 관행과 용어 사전이다. 태국에서 "standard quality"가 의미하는 수준과 한국에서의 의미가 다르다. 중국 공급사가 "fast delivery"라고 했을 때의 실제 리드타임. 이런 암묵적 지식을 명시적 데이터로 정리한 것이다.
# 지식 베이스 구조
KNOWLEDGE_LAYERS = {
"L1_transactions": {
"source": "REINDEERS 거래 DB",
"record_count": "25,000+",
"update_frequency": "near-realtime",
"chunk_strategy": "per_transaction",
"fields": [
"product_name", "hs_code", "origin_country",
"destination_country", "unit_price", "currency",
"trade_terms", "payment_terms", "lead_time_days",
"vendor_id", "buyer_id", "quality_rating"
]
},
"L2_documents": {
"source": "Document AI 추출 결과",
"update_frequency": "on_creation",
"chunk_strategy": "per_document_section",
"doc_types": [
"invoice", "purchase_order", "customs_declaration",
"certificate_of_origin", "packing_list", "bill_of_lading"
]
},
"L3_regulations": {
"source": "FDA/TISI/KC 크롤러",
"update_frequency": "daily",
"chunk_strategy": "per_regulation_item",
"data_types": [
"hs_code_requirements", "certification_rules",
"import_restrictions", "tariff_rates"
]
},
"L4_operations": {
"source": "DVRP + 포워딩 비딩",
"update_frequency": "daily",
"chunk_strategy": "per_route_period",
"metrics": [
"transit_time_by_route", "forwarder_scores",
"port_congestion", "seasonal_rate_patterns"
]
},
"L5_communication": {
"source": "운영팀 수동 정리 + NLP 추출",
"update_frequency": "monthly",
"chunk_strategy": "per_concept",
"content": [
"trade_terminology_by_country",
"negotiation_patterns",
"quality_standard_mappings"
]
}
}
데이터 파이프라인: 원시 데이터에서 벡터까지
원시 데이터가 검색 가능한 지식이 되려면 여러 단계를 거쳐야 한다. 수집 → 정제 → 청킹 → 임베딩 → 인덱싱. 각 단계에 무역 도메인 특유의 문제가 있다.
수집과 정제
데이터 수집은 층마다 다른 방식이다. 거래 데이터는 REINDEERS DB에서 직접 가져오고, 서류 데이터는 Document AI의 OCR+추출 파이프라인을 거치고, 규제 데이터는 웹 크롤러가 수집한다.
정제 단계에서 가장 큰 문제는 초기 데이터의 비일관성이다. 11년간 축적된 데이터인데, 초기에는 데이터 표준이 없었다. 같은 제품이 "Ball Bearing 6205", "베어링 6205", "ลูกปืนเหล็ก 6205"으로 기록되어 있다. 세 가지 언어, 세 가지 표기. 이걸 정규화하지 않으면 검색이 제대로 안 된다.
# 데이터 정제 파이프라인
class TradeDataCleaner:
def clean_transaction(self, raw):
"""거래 데이터 정제"""
cleaned = {
# 1. 제품명 정규화: 다국어 → 영문 표준명 + 원어 보존
"product_name_std": self.normalize_product_name(
raw["product_name"],
raw.get("product_name_local")
),
"product_name_original": raw["product_name"],
# 2. HS코드 검증: 6자리/10자리 형식 통일
"hs_code": self.validate_hs_code(raw.get("hs_code", "")),
# 3. 가격 정규화: 통화 명시, 단위 통일
"unit_price": {
"amount": Decimal(str(raw["price"])),
"currency": self.detect_currency(raw),
"per_unit": self.normalize_unit(raw.get("unit", "pc"))
},
# 4. 국가 코드 표준화: ISO 3166-1 alpha-2
"origin": self.normalize_country(raw.get("origin")),
"destination": self.normalize_country(raw.get("destination")),
# 5. 날짜 형식 통일: ISO 8601
"transaction_date": self.parse_date(raw["date"]),
# 6. 무역 조건 표준화: "FOB Bangkok" → {"terms": "FOB", "port": "Bangkok"}
"trade_terms": self.parse_trade_terms(raw.get("terms"))
}
return cleaned
def normalize_product_name(self, name, local_name=None):
"""다국어 제품명을 표준 영문명으로 정규화"""
# 태국어/한국어/중국어 감지
detected_lang = detect_language(name)
if detected_lang != "en":
# 제품 사전에서 매칭 시도 (자체 구축 사전)
std_name = self.product_dictionary.lookup(name)
if std_name:
return std_name
# 사전 미등록: LLM으로 번역 + 수동 검증 큐에 추가
translated = self.translate_product_name(name, detected_lang)
self.review_queue.add(name, translated)
return translated
return self.standardize_english_name(name)
제품명 정규화는 자동화의 한계가 분명한 영역이다. "SKF 6205-2RS" 같은 표준 규격품은 사전 매칭으로 해결되지만, "태국산 고품질 특수 베어링" 같은 서술형 제품명은 어렵다. 현재는 자동 정규화 + 수동 검증 큐를 병행한다. 목표 자동 처리율은 약 75%, 나머지 25%는 운영팀이 확인한다. 이 비율을 90% 이상으로 올리는 게 과제다.
청킹 전략
RAG에서 청킹(chunking)은 검색 품질을 결정하는 핵심 요소다. 일반적인 텍스트 RAG는 "500토큰 단위로 자르고 100토큰 오버랩"같은 방식을 쓴다. 무역 데이터에는 이게 안 맞는다.
무역 데이터의 자연스러운 단위는 거래(transaction)다. 한 건의 거래에 관련된 모든 정보 , 제품, 가격, 조건, 경로, 결과 , 가 하나의 청크에 들어가야 한다. 거래를 잘라서 앞부분(제품 정보)과 뒷부분(결과)이 다른 청크에 가면, "이 제품의 평균 리드타임"을 묻는 질문에 제대로 답할 수 없다.
# 청킹 전략: 데이터 유형별 차별화
class TradeChunker:
def chunk_transaction(self, transaction):
"""거래 데이터: 거래 단위로 하나의 청크"""
text = self._transaction_to_text(transaction)
metadata = {
"layer": "L1_transactions",
"type": "transaction",
"transaction_id": transaction["id"],
"country_origin": transaction["origin"],
"country_dest": transaction["destination"],
"product_category": transaction["category"],
"hs_code_prefix": transaction["hs_code"][:4],
"date_range": transaction["date"][:7], # YYYY-MM
"partner_ids": [
transaction["buyer_id"],
transaction["vendor_id"]
]
}
return {"text": text, "metadata": metadata}
def chunk_document(self, document):
"""서류 데이터: 섹션 단위로 분할, 문서 메타 공유"""
chunks = []
sections = self._split_by_section(document)
for i, section in enumerate(sections):
chunks.append({
"text": section["content"],
"metadata": {
"layer": "L2_documents",
"type": document["doc_type"],
"document_id": document["id"],
"section_index": i,
"section_type": section["type"],
"related_transaction": document.get("transaction_id"),
"date": document["created_at"][:10]
}
})
return chunks
def chunk_regulation(self, regulation):
"""규제 데이터: HS코드 + 규제 항목 단위"""
return {
"text": self._regulation_to_text(regulation),
"metadata": {
"layer": "L3_regulations",
"type": "regulation",
"country": regulation["country"],
"hs_code": regulation["hs_code"],
"regulation_type": regulation["type"],
"effective_date": regulation["effective_date"],
"last_updated": regulation["updated_at"]
}
}
def _transaction_to_text(self, t):
"""거래 데이터를 자연어 텍스트로 변환"""
return (
f"Transaction {t['id']}: "
f"{t['product_name']} (HS: {t['hs_code']}), "
f"from {t['origin']} to {t['destination']}, "
f"unit price {t['unit_price']} {t['currency']} "
f"({t['trade_terms']}), "
f"quantity {t['quantity']} {t['unit']}, "
f"lead time {t['lead_time']} days, "
f"vendor: {t['vendor_name']}, "
f"quality rating: {t['quality_rating']}/5, "
f"date: {t['date']}"
)
메타데이터가 핵심이다. 각 청크에 국가, 제품 카테고리, HS코드 접두어, 날짜 범위, 관련 파트너 ID를 태그한다. 이 메타데이터가 검색 시 필터링의 기반이 된다. "태국 베어링 수입 가격"이라는 질문이 들어오면, 벡터 유사도 검색 전에 메타데이터 필터로 country_dest=TH, product_category=bearings를 먼저 적용한다. 이렇게 하면 검색 공간이 대폭 줄어들고, 관련 없는 데이터가 섞일 확률이 낮아진다.
임베딩과 인덱싱
청크를 벡터로 변환하는 임베딩 단계에서 도메인 특수성 문제가 드러났다. 범용 임베딩 모델(OpenAI text-embedding-3, Cohere embed-v3 등)은 일반 텍스트에서 뛰어나지만, 무역 도메인의 전문 용어를 잘 이해하지 못한다.
예를 들어 "FOB Laem Chabang"과 "CIF Busan"은 무역에서 매우 다른 조건이다. FOB는 선적항에서 비용 책임이 넘어가고, CIF는 목적항까지 매도인이 보험과 운임을 부담한다. 하지만 범용 임베딩 모델에게 이 두 텍스트는 "항구 이름이 포함된 비슷한 무역 용어" 정도로 인식된다. 의미적 차이가 벡터 공간에 제대로 반영되지 않는다.
# 도메인 적응 임베딩 학습 데이터 예시
CONTRASTIVE_PAIRS = [
# 유사해야 하는 쌍 (positive pairs)
{
"anchor": "FOB Bangkok, 베어링 6205, USD 2.50/pc",
"positive": "FOB Laem Chabang, ball bearing 6205-2RS, 2.45 USD per piece",
"explanation": "같은 조건, 같은 제품, 유사 가격"
},
# 달라야 하는 쌍 (negative pairs)
{
"anchor": "FOB Bangkok, 베어링 6205, USD 2.50/pc",
"negative": "CIF Busan, 베어링 6205, USD 4.80/pc",
"explanation": "같은 제품이지만 무역조건과 가격 구조가 완전히 다름"
},
{
"anchor": "HS 8482.10 , 볼 베어링",
"negative": "HS 8483.30 , 베어링 하우징",
"explanation": "HS코드 유사하지만 완전히 다른 제품군"
}
]
# 학습 과정 (contrastive learning)
# 1. 무역 데이터에서 자연스러운 양성/음성 쌍 추출
# 2. 같은 거래의 PO와 Invoice → 양성 쌍
# 3. 같은 HS코드 다른 거래조건 → 음성 쌍
# 4. 기존 임베딩 모델을 베이스로 fine-tune
솔직히 말하면 도메인 적응 임베딩은 아직 실험 단계다. 현재 운영 환경에서는 범용 임베딩 + 강력한 메타데이터 필터링으로 충분한 품질이 나오고 있다. 도메인 임베딩은 데이터가 더 쌓이고 검색 품질의 한계가 분명해지면 본격적으로 투자할 영역이다.
검색 전략: 벡터만으로는 부족하다
RAG의 검색 품질은 곧 답변 품질이다. 아무리 좋은 LLM이라도 엉뚱한 데이터가 컨텍스트로 들어오면 엉뚱한 답을 낸다. 검색 전략은 세 가지를 결합한 하이브리드 방식이다.
# 하이브리드 검색 전략
class HybridRetriever:
def retrieve(self, query, user_context):
"""하이브리드 검색: 메타 필터 → 벡터 + 키워드 → 리랭킹"""
# 1단계: 쿼리 분석 , 검색 의도와 필터 조건 추출
query_analysis = self.analyze_query(query, user_context)
# → {"intent": "price_comparison",
# "filters": {"country": "TH", "category": "bearings"},
# "keywords": ["베어링", "6205", "수입", "단가"],
# "date_preference": "recent"}
# 2단계: 메타데이터 필터링 , 검색 공간 축소
candidate_pool = self.metadata_filter(
country=query_analysis["filters"].get("country"),
category=query_analysis["filters"].get("category"),
date_range=query_analysis.get("date_preference"),
tenant_id=user_context["tenant_id"] # 테넌트 격리
)
# 3단계: 벡터 유사도 검색
vector_results = self.vector_search(
query_embedding=self.embed(query),
candidates=candidate_pool,
top_k=30
)
# 4단계: 키워드 매칭 (BM25)
keyword_results = self.keyword_search(
keywords=query_analysis["keywords"],
candidates=candidate_pool,
top_k=30
)
# 5단계: 결과 병합 + 리랭킹
merged = self.reciprocal_rank_fusion(
vector_results, keyword_results,
vector_weight=0.6,
keyword_weight=0.4
)
# 6단계: 최종 리랭킹 , 최신성, 데이터 품질 반영
reranked = self.rerank(
candidates=merged[:20],
factors={
"recency": 0.3, # 최근 데이터 우선
"relevance": 0.5, # 검색 관련성
"data_quality": 0.2 # 데이터 품질 점수
}
)
return reranked[:5] # 상위 5건을 LLM 컨텍스트로 전달
실제 검색 과정을 구체적으로 따라가 보자. 태국 바이어가 "중국산 6205 베어링의 최근 수입 단가가 어느 정도인가요?"라고 질문한다.
쿼리 분석 단계에서 LLM이 질문을 분석한다. 의도는 가격 비교, 필터 조건은 origin=CN + product=bearing 6205, 시간 선호는 최근(recent)이다. 이 분석 결과로 메타데이터 필터가 설정된다.
메타데이터 필터를 먼저 적용하면 전체 25,000건에서 중국 → 태국 경로의 베어링 관련 거래 약 800건(예시 수치)으로 줄어든다. 여기에 최근 1년 필터를 추가하면 약 200건이 된다. 이 200건에 대해서만 벡터 유사도와 키워드 매칭을 수행한다. 전체 데이터에 대해 벡터 검색을 하는 것보다 빠르고, 결과도 정확하다.
벡터 검색과 키워드 검색의 결과를 Reciprocal Rank Fusion(RRF)으로 병합한다. 벡터 검색은 의미적 유사성을 잡고, 키워드 검색은 "6205"같은 정확한 모델명 매칭을 보장한다. 벡터 검색만 쓰면 "6205"를 "6204"와 비슷하다고 판단할 수 있다. 베어링 모델 번호에서 한 자리 차이는 완전히 다른 규격이다.
마지막 리랭킹에서 최신성 가중치가 적용된다. 무역에서 3년 전 가격은 참고 정도의 가치밖에 없다. 6개월 이내의 거래에 높은 가중치를 부여하고, 1년 넘은 데이터는 가중치를 낮춘다. 데이터 품질 점수는 해당 거래 데이터가 얼마나 완전한지(필드 입력률)와 검증 여부를 반영한다.
컨텍스트 윈도우 관리
LLM의 컨텍스트 윈도우는 크지만 무한하지 않다. 그리고 컨텍스트가 길어질수록 모델의 주의력(attention)이 분산된다. "중간에 넣은 정보"가 무시되는 Lost in the Middle 현상도 있다. 검색 결과를 어떤 순서로, 얼마나 넣느냐가 답변 품질에 직접 영향을 준다.
# 컨텍스트 구성 우선순위
class ContextBuilder:
MAX_CONTEXT_TOKENS = 8000 # LLM에 넘기는 최대 토큰
def build_context(self, query, retrieved_docs, user_context):
"""우선순위 기반 컨텍스트 구성"""
sections = []
remaining_tokens = self.MAX_CONTEXT_TOKENS
# 우선순위 1: 사용자 쿼리 컨텍스트 (절대 생략 불가)
query_section = self._format_query_context(query, user_context)
remaining_tokens -= count_tokens(query_section)
sections.append(query_section)
# 우선순위 2: 직접 관련 데이터 (상위 3건)
for doc in retrieved_docs[:3]:
formatted = self._format_document(doc)
tokens = count_tokens(formatted)
if tokens <= remaining_tokens:
sections.append(formatted)
remaining_tokens -= tokens
# 우선순위 3: 보조 데이터 (4~5번째)
for doc in retrieved_docs[3:5]:
summary = self._summarize_document(doc) # 요약 버전
tokens = count_tokens(summary)
if tokens <= remaining_tokens:
sections.append(summary)
remaining_tokens -= tokens
# 우선순위 4: 시스템 지침
if remaining_tokens > 500:
instructions = self._get_system_instructions(
query_analysis["intent"]
)
sections.append(instructions)
return "\n\n---\n\n".join(sections)
상위 3건은 전체 텍스트로 넣고, 4~5번째는 요약 버전으로 넣는다. 이렇게 하면 토큰을 절약하면서도 관련 데이터의 다양성을 유지할 수 있다. 실험 결과, 전체 텍스트 5건보다 전체 3건 + 요약 2건이 답변 정확도가 높았다. 관련도가 낮은 데이터의 전체 텍스트는 노이즈가 되기 때문이다.
품질 측정과 피드백 루프
RAG 시스템은 만들고 끝이 아니라 계속 개선해야 한다. 문제는 "검색 품질이 좋다/나쁘다"를 어떻게 측정하느냐다.
우리가 추적하는 지표는 세 가지다.
검색 적중률(Retrieval Hit Rate). 사용자가 한 질문에 대해 반환된 5건의 문서 중, 실제로 답변 생성에 사용된 문서가 몇 건인지. LLM의 답변에 인용된 문서 ID를 추적한다. 적중률이 낮으면 검색이 엉뚱한 데이터를 가져오고 있다는 신호다.
답변 채택률. AI 어시스턴트가 제시한 답변을 사용자가 실제로 활용했는지. "이 가격대가 맞습니다"라는 답변 후 사용자가 해당 가격으로 견적을 진행했으면 채택이고, 무시하거나 다른 수치를 사용했으면 미채택이다. 이건 간접 지표지만 장기적으로 가장 의미 있다.
사용자 수정 빈도. AI가 제시한 정보를 사용자가 수정한 경우를 추적한다. "AI가 제안한 HS코드를 변경했다" "AI가 추천한 공급사가 아닌 다른 공급사를 선택했다" 등. 수정이 잦은 패턴을 분석하면 지식 베이스의 약점이 드러난다.
# 피드백 루프
class RAGFeedbackLoop:
def record_interaction(self, query, retrieved_docs, response, user_action):
"""사용자 인터랙션 기록 + 품질 신호 추출"""
feedback = {
"query": query,
"retrieved_doc_ids": [d["id"] for d in retrieved_docs],
"cited_doc_ids": self.extract_citations(response),
"user_action": user_action, # adopted, modified, ignored
"timestamp": now()
}
# 적중률 계산
cited = set(feedback["cited_doc_ids"])
retrieved = set(feedback["retrieved_doc_ids"])
feedback["hit_rate"] = len(cited & retrieved) / len(retrieved)
# 수정 시: 수정 내용을 학습 데이터로 활용
if user_action == "modified":
correction = self.extract_correction(response, user_action)
self.learning_queue.add({
"original_query": query,
"ai_response": response,
"user_correction": correction,
"correction_type": self.classify_correction(correction)
})
self.store(feedback)
def analyze_weak_spots(self, period_days=30):
"""약점 분석: 어떤 유형의 질문에서 품질이 낮은가"""
recent = self.get_feedbacks(days=period_days)
weak_categories = {}
for fb in recent:
if fb["hit_rate"] < 0.4 or fb["user_action"] == "ignored":
category = fb.get("query_category", "uncategorized")
if category not in weak_categories:
weak_categories[category] = []
weak_categories[category].append(fb)
return sorted(
weak_categories.items(),
key=lambda x: len(x[1]),
reverse=True
)
솔직히 어려운 것들
깔끔하게 정리된 아키텍처 뒤에는 아직 해결하지 못한 문제들이 있다. 숨길 이유가 없으니 솔직하게 쓴다.
초기 데이터의 품질. 11년간의 데이터가 전부 깨끗할 리가 없다. 초기 5년 정도의 데이터는 형식이 일관되지 않고, 누락 필드가 많고, 일부는 수기 입력의 오타까지 있다. 이 데이터를 지식 베이스에 넣으면 오히려 노이즈가 된다. 현재는 데이터 품질 점수가 일정 수준 이상인 데이터만 인덱싱하고, 나머지는 정제가 완료될 때까지 제외하고 있다. 체감상 약 60%의 데이터만 활용 가능한 상태다.
다국어 혼합. 하나의 거래에 태국어, 한국어, 중국어, 영어가 섞여 있는 경우가 흔하다. 제품명은 태국어, 무역 조건은 영어, 비고란은 한국어. 이걸 어떤 언어로 임베딩해야 하는가? 현재는 영어로 번역한 버전과 원어 버전을 모두 임베딩하고, 두 벡터를 모두 인덱싱한다. 저장 공간과 검색 시간이 2배가 되지만, 다국어 검색 정확도를 위한 비용이라고 판단했다.
시의성(staleness) 판단. 3개월 전 베어링 가격 데이터가 아직 유효한가? 규제 데이터가 마지막 크롤링 이후 변경되었을 가능성은? 데이터의 유효기간을 자동으로 판단하는 건 생각보다 어렵다. 현재는 단순한 규칙 기반이다. 가격 데이터는 6개월, 규제 데이터는 갱신일 기준 3개월을 유효기간으로 본다. 유효기간이 지난 데이터는 검색 결과에 "이 정보는 N개월 전 데이터입니다. 최신 확인이 필요합니다"라는 경고를 붙인다.
도메인 임베딩의 비용 대비 효과. 앞서 contrastive learning 기반 도메인 임베딩을 소개했는데, 솔직히 아직 프로덕션에 적용하지 못했다. 이유는 학습 데이터 구축 비용이다. 양성/음성 쌍을 만들려면 무역 도메인 전문가가 직접 레이블링해야 한다. 우리 팀에서 이걸 할 수 있는 사람이 제한적이다. 범용 임베딩 + 메타데이터 필터링의 조합이 "충분히 좋은" 품질을 내고 있어서, 도메인 임베딩은 다음 분기 이후로 미뤘다.
실제 질의 응답 사례
이론적인 아키텍처보다 실제로 어떤 식으로 작동하는지가 더 와닿을 거다. 이런 방식으로 작동하도록 설계한 시연 환경에서의 예시를 보여준다.
한국의 바이어 담당자가 AI 어시스턴트에 질문한다. "태국에서 6205 베어링 수입하려는데, 최근 단가 트렌드가 어떻게 되나요? 그리고 TISI 인증이 필요한가요?"
이 질문에는 두 가지 의도가 섞여 있다. 가격 트렌드 조회와 인증 요건 확인. 쿼리 분석 단계에서 이 두 가지를 분리하고, 각각에 맞는 검색을 수행한다.
가격 트렌드를 위해서는 Layer 1(거래 이력)에서 origin=TH, product=bearing 6205, 최근 12개월 필터로 검색한다. 8건의 관련 거래가 검색된다. 인증 요건을 위해서는 Layer 3(규제 데이터)에서 country=TH, hs_code=8482.10 필터로 검색한다. TISI 인증 관련 규제 데이터 2건이 검색된다.
검색된 10건 중 가격 관련 상위 3건의 전체 데이터와, 규제 관련 2건의 전체 데이터가 LLM에 컨텍스트로 전달된다. LLM은 이를 종합하여 이렇게 답한다. "최근 12개월간 태국발 6205 베어링 수입 단가는 건당 USD 2.30~2.65 범위에서 거래되었습니다. 최근 3개월은 평균 USD 2.48로 소폭 상승 추세입니다. 가장 최근 거래(2026년 3월, TXN-2026-03-0187)는 FOB Bangkok 기준 USD 2.55/pc였습니다. HS코드 8482.10 기준으로 태국 TISI 인증은 수출 시 필수가 아니지만, 한국 수입 시 KC 인증 여부를 확인하시기 바랍니다."
모든 수치에 근거가 되는 거래 ID와 규제 문서가 명시된다. 담당자는 해당 거래 이력을 직접 확인할 수도 있다. 이게 RAG의 핵심 가치다. 답을 만들어내는 게 아니라, 실제 데이터에 근거한 답을 제공하는 것이다.
데이터가 쌓일수록 좋아진다
RAG 지식 베이스의 매력은 시간이 지날수록 성능이 좋아진다는 점이다. 거래가 쌓이면 가격 비교의 근거가 풍부해지고, 서류가 쌓이면 패턴 인식이 정교해지고, 사용자 피드백이 쌓이면 검색 품질이 개선된다.
하지만 이건 자동으로 되는 게 아니다. 데이터가 늘어나면 노이즈도 늘어난다. 인덱스가 커지면 검색 속도가 느려진다. 오래된 데이터와 새 데이터가 섞이면 최신성 판단이 어려워진다. "더 많은 데이터 = 더 좋은 AI"는 정제 파이프라인과 품질 관리가 따라올 때만 성립하는 명제다.
지금 REINDEERS의 AI는 "무역을 안다"고 말하기에는 이르다. 무역 데이터를 참조해서 답할 수 있는 수준이다. 하지만 25,000건이 50,000건이 되고, 피드백 루프가 수만 번 반복되면, "무역을 이해한다"에 가까워질 거라고 본다. 그 과정에서 가장 중요한 건 화려한 알고리즘이 아니라, 데이터의 품질을 끈질기게 관리하는 일이라는 걸 매일 체감하고 있다.
RAG의 최종 수혜자는 AI Agent다
이 글을 여기서 끝내면 "RAG로 똑똑한 챗봇 만들기"처럼 읽힐 수 있어서, 한 가지를 더 덧붙이겠다. 이 지식 베이스의 최종 수혜자는 사람 사용자가 아니라, 조직도 안에 등록된 AI Agent들이다.
현재는 사람 담당자가 질문을 하고 AI가 답을 한다. 다음 단계는 이렇다. 구매 Agent가 "태국 A사에서 베어링 수입할 때 최근 3개월 평균 단가가 얼마였지?"를 자기 자신에게 묻는다. RAG에서 근거 거래 8건을 가져와 평균을 계산한다. 그 평균과 오늘 A사가 보내온 견적을 비교해서 "허용 범위 안"이라고 판단하면 바로 발주서 초안을 생성한다. "허용 범위 밖"이면 사람에게 올린다. 통관 Agent는 HS코드별 규제 데이터를 RAG에서 조회해 서류가 맞는지 스스로 체크한다. 재무 Agent는 환율 이력과 정산 내역을 RAG에서 조회해 정산금이 정합한지 확인한다.
이 모델에서 Agent가 범용 LLM과 다른 점은 딱 하나다. 답의 근거가 전부 우리 데이터베이스 안에 있다는 것. "어떤 거래 ID에서 이 단가가 나왔고, 어떤 규제 문서에서 이 요건이 나왔는가"가 Agent의 모든 결정에 붙어 있다. 그래서 사람이 나중에 "왜 그 결정을 내렸냐"고 감사할 수 있다. 환각이 발생할 수가 없는 구조다. ChatGPT 같은 외부 AI는 이 구조를 만들 수 없다. 회사의 거래 로그, 규제 데이터, Event+State+Log가 한 자리에 모여 있어야만 가능하다.
AI Agent 진화 4단계(Tool → Assistant → Agent Team → Autonomous Operator)로 보면, RAG 지식 베이스는 모든 단계의 연료다. Tool 단계에서는 사람이 묻고 RAG가 답한다. Assistant 단계에서는 Agent가 RAG로 제안을 만들고 사람이 승인한다. Agent Team 단계에서는 여러 부서 Agent가 동시에 같은 RAG를 각자의 관점으로 읽으며 자율 실행한다. Autonomous Operator 단계에서는 사람이 "올해는 중국 공급사 다변화를 우선한다" 같은 방향만 내리고, Agent가 RAG 안의 11년치 데이터를 끌어와서 그 방향을 구체적인 발주와 정산으로 풀어낸다. 단계가 올라갈수록 RAG의 품질과 커버리지가 결정적으로 중요해진다. 이 글에서 말한 "데이터 품질을 끈질기게 관리하는 일"이 결국 AI 전환의 가장 낮은 층이자 가장 중요한 층이다.
