4개 언어, 2개 알파벳, 107개의 난해한 색상 이름을 위한 검색 엔진 구축하기 (Elasticsearch + pgvector +
요약
다양한 언어와 스크립트가 혼재된 패션 마켓플레이스 환경에서 Elasticsearch와 pgvector, Gemini 임베딩을 활용해 고도화된 검색 엔진을 구축한 사례를 소개합니다. 문자 폴딩, 유의어 매핑, edge_ngram 최적화 등을 통해 오타와 다국어 쿼리 문제를 해결하는 실무적인 아키텍처를 다룹니다.
핵심 포인트
- 문자 폴딩과 음차를 통해 키릴 문자와 라틴 문자 간의 검색 격차 해소
- 유의어 그룹과 퍼지 매칭을 결합하여 속어 및 오타 대응력 강화
- 인덱스 시점과 쿼리 시점의 edge_ngram 분리 사용으로 검색 정확도 향상
- 토큰 분류기를 통한 쿼리의 구조화된 의도(성별, 카테고리 등) 추출
아제르바이잔에 있는 제 패션 마켓플레이스의 실제 운영 쿼리 예시입니다:
гара йупка # "검은색 스커트" — 아제르바이잔어, 키릴 문자 (Cyrillic script)
pembe yubka # "분홍색 스커트" — 절반은 터키어, 절반은 러시아어
roziviy platya # "분홍색 드레스" — 러시아어 음차 (translit), 오타 포함
제 카탈로그는 아제르바이잔어(qara ətək, çəhrayı don)로 작성되어 있습니다. 일반적인 Elasticsearch는 이 세 가지 쿼리 모두에 대해 아무것도 반환하지 않거나, 확신에 찬 오답을 내놓습니다. 여기 이를 해결하는 전체 아키텍처와 실제 적용된 기술, 그리고 그 과정에서의 실패 사례를 소개합니다. 스택(Stack): Django, Elasticsearch 8, PostgreSQL + pgvector, Gemini 임베딩 (embeddings). 팀 구성: 창업자 1명 + AI 페어 프로그래밍.
먼저 작동 모습을 보고 싶으신가요? 실제 엔진의 90초 데모:
1. 스크립트 폴딩 (Script folding) + 유의어 (synonyms) (기초 단계)
모든 것은 분석(analysis) 단계에서의 문자 폴딩 (character folding) 및 음차 (transliteration)에서 시작됩니다: ə→e, ş→s, 키릴 문자(Cyrillic) → 라틴 문자(Latin) (гара→qara). 여기에 더해, 사전적 언어가 아닌 **거리의 언어 (street language)**를 매핑하는 103개의 큐레이션된 유의어 그룹을 추가했습니다. 예를 들어, 슬리퍼 그룹은 tərlik, terlik, тапочки, шлёпки, slippers, səndəl을 포함합니다 (5개의 언어/레지스터 → 하나의 카탈로그 개념). 또한 스웨이드(suede) 그룹은 사용자들이 실제로 입력하는 속어 철자들을 포함합니다 (zamuj, zamıj, zamsha → zamşa).
보너스 메커니즘: 유의어 키(synonym keys) 자체에 퍼지 매칭 (fuzzy-matched)을 적용했습니다 (임계값 0.78, 조정 가능). "yupka"는 어떤 그룹에도 없지만 "yubka"는 있습니다. 이 브릿지(bridge)가 오타를 키(key)에 연결하고, 키는 카탈로그 용어로 확장됩니다: yupka → yubka → ətək. 두 번의 홉(hop)만으로 사용자 마찰 없이 연결됩니다. 임계값을 0.82로 높이면 이 브릿지는 조용히 작동을 멈춥니다. 이는 고정된 값이 아니라 측정 가능한 조절 노브(knob)입니다.
수개월 동안 "왜 이게 매칭되지?"라는 의문에 빠지는 것을 방지해 주는 한 가지 분석기(analyzer) 원칙은 바로 인덱스 시점에만 edge_ngram을 사용하는 것입니다. 인덱스 시점의 custom_analyzer는 앞부분 접두사(front-prefixes)를 생성하여 자동 완성(autocomplete) 재현율(recall)을 높입니다. 반면, 쿼리 시점에는 ngram이 없는 별도의 search_analyzer를 사용해야 합니다. 그렇지 않으면 쿼리의 모든 2글자 조각이 접두사 매처(prefix matcher)가 되어 결과가 쓰레기 데이터로 가득 차게 됩니다. 저희는 "el"이라는 조각이 장갑(gloves), 드레스(dresses), 그리고 상점 설명에 동시에 매칭되었던 운영 환경(prod)의 버그를 통해 이 사실을 배웠습니다.
2. 쿼리 → 구조화된 의도 (structured intent)
모든 쿼리는 토큰 분류기(token classifier)를 통과하며, 각 토큰을 4개 언어 모두에 대해 동시에 실시간 분류 체계(taxonomy)와 대조하여 우선순위에 따라 테스트합니다: 성별(gender) → 카테고리(category) → 하위 카테고리(subcategory) → 색상/사이즈(color/size) → 가격(price).
"pembe yubka" → { color_family: "cehrayi", category: "Skirts", text: "" }
다중 단어 하위 카테고리는 토큰 커버리지 점수(token-coverage scoring)를 사용하는 슬라이딩 윈도우 구절 매처(sliding-window phrase matcher)를 사용합니다:
for size in (3, 2):
for window in windows(tokens, size):
for sub, field, name_tokens in candidates: # 불용어(stopwords) 제거됨
...
따라서 "ətək dəsti" (스커트 세트)는 "Ətək və Üst Geyim Dəsti"라는 하위 카테고리와 하나의 _구절(phrase)_로 매칭됩니다. 즉, 단일 모호한 토큰이 필터를 가로챌 수 없습니다.
단순 매칭(Naive matching)에도 방어 기제가 필요합니다. 방어 기제가 없다면, "ayaqqabısı" (신발을 뜻하는 긴 단어)가 사이즈 "S"와 퍼지 매칭(fuzzy-match)될 수 있습니다. 길이 비율(length-ratio) 규칙과 사이즈 키워드 목록은 실제 사이즈 쿼리는 보호하면서 실수로 인한 매칭은 차단합니다. 가격 힌트도 파싱됩니다. "50 manatadək"는 {price_max: 50}가 되고, "ucuz"는 저가 범위 필터가 됩니다.
엔드 투 엔드(End-to-end)로 보면, 혼합 언어 한 줄인 qadın pembe yubka 50 manatadək (AZ + TR + RU-translit + AZ price)는 다음과 같이 파싱됩니다:
{ gender: "women", # AZ 컬럼 매치
color_families: ["pink"], # TR 별칭(alias)
category: "Skirts", # RU 단어 → 유의어 풀(synonym pool) → ətək
...
자유 텍스트로 남겨지는 토큰은 하나도 없습니다. pembe를 영어인 pink로 바꿔도 파싱 결과는 동일합니다. 별칭(aliases)이 4개 언어를 모두 포함하고 있으므로, 한 문장 안에 언어를 자유롭게 섞어서 사용할 수 있습니다.
3. 색채 과학: 107개의 벤더 문자열 → 15개 계열
벤더(Vendors)들은 파이프 기호("Qırmızı | Açıq çəhrayı")나 실제 CSS를 포함하여 107개의 서로 다른 색상 값을 작성했습니다:
radial-gradient(circle, #000000 20%, #C69258 20%) # 표범 무늬 (a leopard print)
해결책은 더 많은 문자열 매칭(string matching)이 아닙니다. 바로 색채 과학(color science)입니다:
HEX_RE = re.compile(r'#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b') # 그라데이션 내부를 포함하여
# 모든 HEX 코드를 찾음
...
모든 계열(family)에는 앵커 헥스(anchor hexes)가 있으며, 모든 벤더 색상은 CIEDE2000에 의해 가장 가까운 앵커에 매핑됩니다. 우리는 공식 Sharma 테스트 벡터를 통해 검증을 마쳤으며, 악명 높은 1.5381 쌍을 포함하여 소수점 넷째 자리까지 9/9를 달성했습니다. 단순한 CIE76 (Lab 공간에서의 유클리드 거리)에서 CIEDE2000으로 업그레이드함으로써, 9개의 경계 색상을 정확하게 재분류했습니다: 머스타드(mustard)→노란색(yellow) (기존: 골드(gold)), 아이보리(ivory)→베이지(beige) (기존: 화이트(white)), 네이비(navy)가 보라색(purple)으로 번지는 현상도 해결되었습니다.
그리고 15개라는 숫자는 현재의 분류 체계(taxonomy)일 뿐, 한계치가 아닙니다. 계열은 관리자 테이블(admin table)에 존재하므로, 16번째 계열을 추가하는 것은 배포(deploy)가 아닌 데이터베이스 행(row)을 추가하는 작업입니다. 더 중요한 점은, 분류기(classifier)에 색상 제한이 전혀 없다는 것입니다. 내일 벤더가 어떤 새로운 색조를 만들어내더라도, 그 헥스(hex) 값은 Lab 공간에 위치하게 되며 자동으로 가장 가까운 계열에 매핑됩니다. 판매자가 늘어남에 따라 색상 어휘는 별도의 허가 없이도 확장되며, 조직은 이를 자연스럽게 흡수합니다.
수학적 계산 위에 적용되는 규칙들:
- 파이프(/) 또는 이중 색상 → 두 가지 계열 모두 포함 (red|pink 세트는 pink 검색 결과에도 나타나야 함)
- 헥스 유도 계열이 4개 이상인 경우 → "multicolor(다색)"로 통합
- 패턴 별칭 (leopard, zebra 등) → multicolor로 처리, 헥스 계열은 건너뜀
색상 변형이 전혀 없는 제품의 경우? 일회성 백필(backfill)을 수행합니다: 제품 사진 → Gemini Flash-Lite → "배경과 피부색은 무시하고, 지배적인 제품 색상을 헥스로 추출" → CIEDE2000 → 계열 분류. 총 비용 0.01달러 미만으로 76개의 제품을 분류했습니다.
전투 사례 하나를 소개하자면, 첫 번째 버전에서는 Gemini 호출 시 max_output_tokens=256으로 설정했습니다. 배포 전 AI 리뷰 에이전트가 이를 지적했습니다. 추론 토큰 (thinking tokens)도 이 예산(budget)을 공유하기 때문에, 모델이 추론 과정에서 제한치를 모두 소모해 버리면 빈 텍스트를 반환할 수 있다는 것이었습니다. 즉, 전체 백필 (backfill) 과정이 아무런 결과물 없이 조용히 진행될 위험이 있었습니다. 결국 제한치를 2048로 높였고, 절단 (truncation) 발생 시 로그를 남기도록 설정했습니다.
4. 실패하는 대신 성능을 저하시키는 필터들
엄격한 필터 (Hard filters)는 빈 페이지를 보여주는 지름길입니다. 모든 필터링된 검색은 계단식으로 내려가야 하며, 결과가 있는 첫 번째 단계에서 멈춰야 합니다:
yield 'full', search_with(category, color, text)
if color:
yield 'no_color', search_with(category, text)
...
잘못된 제약 조건은 랭킹 품질 (ranking quality)을 떨어뜨릴 뿐, 화면을 빈 상태로 만들지는 않습니다.
두 번째 관용 메커니즘은 **분류 체계 브릿지 (taxonomy bridge)**입니다. 예를 들어 "yubka"는 Skirts 카테고리를 감지하지만, 스커트 세트는 하위 카테고리 이름이 문자 그대로 "Skirt & Top Set"인 다른 카테고리에 속해 있습니다. 엄격한 category.id 필터는 이들을 조용히 숨겨버렸습니다. 이제 필터는 다음과 같이 구성됩니다:
Q('bool', should=[
Q('term', **{'category.id': detected_id}),
Q('match', **{'subcategory.title': { # 개념 브릿지 (concept bridge)
...
브릿지가 너무 넓게 확장되는 것을 방지하기 위해 일반적인 토큰 (clothing, set, top…)은 제외합니다. 만약 일반적인 토큰만 남게 된다면 브릿지는 구축되지 않습니다. (참고: AI 코드 리뷰 에이전트가 배포 전 과도한 확장 위험을 포착했습니다.)
5. 시맨틱 레이어 (Semantic layer): 하나의 임베딩 공간, 두 개의 문
- 텍스트 및 이미지 임베딩은 단일 공유 1536차원 공간 (Gemini Embedding 2, 멀티모달)에 존재하며, 인덱스 재구축 시에도 유지되는 pgvector에 저장됩니다.
- 어휘적 (lexical) 결과가 약할 때(임계값 미만일 때), 우리는 텍스트-kNN과 이미지-kNN을 실행하고 **상호 순위 결합 (Reciprocal Rank Fusion, RRF)**을 통해 이를 융합합니다:
def rrf_merge(*ranked_lists, k=60, limit=None):
scores = defaultdict(float)
for lst in ranked_lists:
...
- 동일한 공간이 시각적 검색 (photo → products)을 구동합니다. 하나의 공간, 두 개의 문입니다.
- 분류 체계 (Taxonomy) 또한 임베딩되어 있습니다. 모든 카테고리/하위 카테고리/성별은 pgvector 내에 해당 제품들의 평균 벡터인 중심점 (Centroid)을 가집니다. 시각적 검색은 랭킹을 매기기 전에 쿼리 사진을 중심점들과 대조하여 분류합니다:
rows = (VisualCentroid.objects
.filter(embedding__isnull=False)
.annotate(distance=CosineDistance('embedding', query_vector))
...
- 결정적으로, 시맨틱 (Semantic) 검색은 약한 어휘적 결과 (Weak lexical results)가 나올 때만 작동합니다. 정확한 일치 (Exact match)가 작동할 때는 느낌 (Vibes)으로 결과를 희석하지 마세요.
벡터 사용자들을 위한 두 가지 운영상의 각주:
SET LOCAL hnsw.iterative_scan = relaxed_order;
pgvector의 HNSW 인덱스는 kNN 쿼리를 사후 필터링 (Post-filter)할 때 (예: status='Published') 조용히 결과 개수를 누락 (Under-returns) 합니다. 인덱스는 K개의 후보를 제공하지만, 필터가 그 중 대부분을 제거하여 실제 40개가 존재함에도 3개의 결과만 받게 됩니다. 우리의 모든 필터링된 kNN은 해당 세션 설정 내에서 실행됩니다.
그리고 최신성 (Freshness): 모든 제품 저장 시 Cloud Task를 전송하여 몇 초 내에 ES 문서를 재동기화합니다. 야간 재빌드나 Celery 워커 없이, Cloud Run에서 HTTP로 트리거되는 태스크만 사용합니다. 판매자가 드레스를 등록하면, 그들이 탭을 닫기도 전에 검색이 가능해집니다.
6. 하나의 두뇌, 세 개의 문: 챗봇은 동일한 엔진에서 실행됩니다
우리의 AI 어시스턴트는 자체적인 제품 지식을 가지고 있지 않습니다. 어시스턴트는 7개의 인증된 도구 엔드포인트를 통해 검색 엔진을 호출합니다 (상수 시간 비교를 통한 API-key 방식, 그리고 서비스 계정을 위한 Google 서명 JWT 사용):
search_products # 동일한 의도 파서 (Intent parser), 동일한 필터
search_shops # 동일한 제품 증거 랭킹 (Product-evidence ranking)
search_faq / search_blog # RAG 코퍼스: FAQ + 문서 + 블로그 청크 (Chunks),
...
시각적 검색은 세 번째 문입니다. 동일한 임베딩, 동일한 중심점, 동일한 랭킹 규칙을 사용합니다. 유의어 (Synonym)를 한 번 수정하면 검색창, 카메라, 챗봇이 모두 같은 순간에 개선됩니다.
7. 데이터 플라이휠 (Data flywheel): BigQuery가 핵심인 이유
모든 의미 있는 상호작용은 구조화된 JSON 이벤트(structured JSON event)를 생성합니다. 18개의 이벤트 유형 중 15개는 Cloud Logging sink를 통해 BigQuery로 라우팅됩니다 (지난 30일 동안 60,000개 이상의 이벤트 발생). 흥미로운 데이터는 페이지뷰(pageviews)가 아닙니다:
log_smart_upload_save(
ai_suggestions=..., # AI가 제안한 내용
vendor_choices=..., # 인간이 결정한 내용
...
이것은 **일반적인 플랫폼 사용을 통해 제조된 레이블링된 학습 쌍 (labeled training pair)**입니다. 모든 곳에서 동일한 패턴이 나타납니다:
SearchHistory는 쿼리 → 클릭한 제품 (텍스트 및 시각적 요소 포함; 시각적 쿼리는 1536차원 임베딩 (embedding)을 유지) → 쿼리-관련성 쌍 (query-relevance pairs)을 저장합니다.- 중재자 거절 (Moderator rejections) 데이터는 카테고리를 포함하며 **부정적 예시 인덱스 (negative-example index)**에 공급됩니다. 새로운 업로드는 Gemini 호출이 이루어지기 _전_에 이 인덱스를 대상으로 kNN 사전 검사를 거칩니다. 거절된 이미지와 유사한 이미지는 밀리초 단위로 식별되어 API 비용을 완전히 건너뜁니다.
- 중재자 검토 대기열 (moderator review queue)은 시각적 검색 결과를 등급화하고 카테고리를 수정합니다 → 수동 레이블링된 정답 (hand-labeled ground truth)이 생성됩니다.
- 결과가 없는 쿼리 (Zero-result queries)는 전용 BigQuery 뷰로 전달됩니다 → 주간 유의어 큐레이션 (synonym curation)에 활용됩니다.
성장 단계: (1) 현재의 대시보드 및 마이닝 → (2) 행동 기반 랭킹 (CTR이 가중치를 조정하고, 측정값에 의해 시맨틱 혼합 비율 (semantic blend ratio)이 설정됨) → (3) 제안-대-결정 쌍 (suggestion-vs-decision pairs) 및 클릭-관련성 쌍 (click-relevance pairs)을 활용한 자체 검색/분류 모델의 미세 조정 (fine-tuning). 다른 누구도 이 데이터셋을 가지고 있지 않습니다. 왜냐하면 다른 누구의 사용자도 "гара йупка"라고 입력하지 않기 때문입니다.
8. 컨트롤 플레인 (The control plane)
모든 AI 동작은 배포 없이도 관리자가 조정할 수 있습니다 — 두 개의 싱글톤 설정 모델(singleton config models)에 걸쳐 **47개의 노브 (knobs)**가 존재합니다 (시맨틱 임계값 (semantic thresholds), RRF k, 컬러 필터 토글, 시각적 유사성 단계, 게이트 모델, 속도 제한, 세션 TTL). 설정 적용 우선순위: DB 재정의 (DB override) → 설정 (settings) → 기본값 (defaults) 순이며, 관리자가 저장할 때 캐시가 무효화됩니다.
임계값은 감(vibes)에 의존하지 않습니다. API 비용이 들지 않는 평가 명령어를 실행하여 모든 제품 임베딩에 대해 Leave-one-out 검증을 수행하고, 세 가지 분류 방법(이웃 투표 (neighbor-vote) / 중심점 (centroid) / 텍스트-RAG argmax)을 비교한 뒤, 정밀도(precision) 95%에 도달하는 임계값 그리드(threshold grid)를 출력합니다.
score ∈ {0.60..0.85} × margin ∈ {0..0.05} → 정밀도/재현율(precision/coverage) 테이블
→ 권장 게이트(recommended gate) = 정밀도(precision) ≥ 0.95인 첫 번째 셀
관리 노브(admin knob)는 벤치마크가 제시하는 값으로 설정됩니다.
비용 또한 제어 평면(control plane)의 일부입니다. Gemini 카탈로그 컨텍스트는 서버 측 컨텍스트 캐시(context cache)에 상주하며(AI 업로드 호출당 프롬프트 토큰의 약 94%가 캐시에서 제공됨), 비전(vision) 입력은 전송 전에 크기가 조정됩니다. 또한, 거부된 이미지에 대한 kNN 사전 검사(pre-check)는 Gemini 호출을 완전히 건너뛰며, 건너뛴 각 호출은 절감된 비용과 함께 로그에 기록됩니다.
추측이 아닌 프로덕션 디버깅
프로덕션 환경에서 "pembe"(분홍색)를 검색했을 때 베이지색과 데님 스커트가 반환되기 시작했을 때, 우리는 맹목적으로 부스트(boost) 값을 조정하지 않았습니다. 우리는 라이브 시스템에서 파싱된 의도(intent)를 추적했습니다:
FILTERS = {'variants': [{'title': 'Açıq Mavi | Krem | Çəhrayı',
'matched_field': 'title_tr', ...}],
'color_families': ['cehrayi', 'bej', 'mavi'], ...}
원인이 바로 여기에 있었습니다. "pembe"가 특정 아이템의 터키어 번역과 정확히 일치(exact-match)하면서, 해당 아이템이 가진 세 가지 색상 계열(color families)을 모두 상속받아 필터를 희석시킨 것입니다. 해결책: 단어 별칭(word-aliases)이 아이템 상속보다 우선순위를 갖도록 설정했습니다. 즉, 분홍색은 분홍색이어야 합니다. 단 한 번의 추적, 한 줄의 코드 수정, 그리고 두 개의 새로운 테스트로 해결되었습니다.
수치
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기