문제 — Lexical 매칭의 한계
전통적 키워드 매칭은 synonym · typo · shorthand · 다국어 혼용에서 실패한다. "soda" vs "soft drink", "gf pizza" → gluten-free 같은 패턴은 키워드 겹침이 없어 검색 결과가 비어버린다.
"Uber Eats 사용자는 'gf pizza'를 검색하고 'soda'를 'soft drink'로 바꿔 찾는다. Lexical 매칭으로는 답을 찾을 수 없는 — billion-scale의 의미 검색을 어떻게 풀었는가."
Uber Eats 검색의 본질은 의미 매칭이다. "soda"를 입력해도 "soft drink"가 잡혀야 하고, "gf pizza"가 gluten-free임을 알아야 한다. Uber는 QWEN 백본의 Two-Tower 시맨틱 검색을 Matryoshka 임베딩 · Lucene Plus · int7 양자화 · 사전 필터링으로 묶어 billion-scale 카탈로그를 밀리초 단위로 푼다.
전통적 키워드 매칭은 synonym · typo · shorthand · 다국어 혼용에서 실패한다. "soda" vs "soft drink", "gf pizza" → gluten-free 같은 패턴은 키워드 겹침이 없어 검색 결과가 비어버린다.
쿼리와 문서를 같은 임베딩 공간으로 매핑하되, 계산은 분리. Query Tower는 온라인 실시간(사용자가 입력할 때마다), Document Tower는 오프라인 배치(스케줄 작업). 두 경로를 분리해 독립 최적화 가능.
두 타워의 백본 모두 QWEN LLM. world knowledge + cross-lingual 능력을 가져오고 Uber Eats 사내 데이터로 fine-tuning. 모든 vertical(레스토랑·grocery·retail), 모든 시장에 단일 모델로 임베딩 — 운영 단순화.
MRL-기반 InfoNCE loss로 학습. 같은 모델이 [128, 256, 512, 768, 1024, 1280, 1536] 7개 차원을 동시에 출력. 256-dim에서 저장공간 -50%, 품질 손실 <0.3%. 비용/품질 트레이드오프 곡선 위 sweet spot.
Lucene Plus를 인덱스 기반으로 — vertical별 분리(OFD/GR). HNSW로 ANN 검색, int7 양자화로 latency 50% 추가 절감(recall 0.95+ 유지). float32와 int8 듀얼 저장도 지원.
ANN 검색 전에 hexagon(지리) · city_id · doc_type · fulfillment_types로 boolean 필터링. 수십억 후보 중 1% 미만만 ANN으로 넘기므로 billion-scale도 ms 단위 응답 가능. 가장 중요한 비용 절감 트릭.
쿼리와 문서를 같은 임베딩 공간으로 사상하되 계산 경로를 둘로 분리. Query Tower는 사용자 요청마다 ms 단위로 인코딩, Document Tower는 격주 스케줄로 카탈로그 전체를 한 번에 인코딩.
분리된 덕분에 — query 측은 latency 최적화, document 측은 throughput · 비용 최적화로 독립적으로 진화 가능. 같은 백본(QWEN)을 사용하므로 공간 일관성은 자동 보장.
document 임베딩을 매 요청마다 다시 계산하면 비용이 폭주. 카탈로그는 분 단위로 변하지 않으니 배치로 충분. 사용자 쿼리만 실시간.
MRL(Matryoshka Representation Learning)은 한 모델이 여러 차원의 임베딩을 동시에 학습하도록 만든다. InfoNCE loss를 7개 차원 cut 각각에 똑같은 가중치로 적용해 어느 차원으로 잘라도 의미가 보존되도록.
결과: 검색 latency와 저장 비용이 가장 큰 256-dim cut을 운영에 사용해도 품질 손실 0.3% 미만. 나머지 차원들은 정확도가 더 중요한 re-ranking 단계용으로 활용 가능.
"품질 vs 비용" 곡선을 한 모델로 모두 커버. 별도 dimensionality reduction (PCA 등) 불필요. 차원별 retraining도 불필요.
billion-scale 카탈로그를 ms 단위로 푸는 비결은 ANN 전에 후보를 99% 이상 깎는 것. 지리적으로 갈 수 없는 음식점, vertical 불일치, fulfillment 타입 불일치는 boolean 필터로 즉시 제거.
남은 후보에 한해서만 HNSW 그래프 위에서 ANN, 그 결과를 작은 신경망으로 micro-re-rank. 각 단계가 다음 단계의 후보를 한 자릿수씩 줄이는 깔때기 구조.
"ANN을 빠르게"보다 "ANN에 넘어가는 후보를 줄이는" 것이 훨씬 효과적. boolean pre-filter는 무료에 가깝다 — index 설계만 잘 하면.
세 개의 독립적 최적화가 누적으로 효과를 낸다 — shard k 튜닝 (1200→200), int7 양자화, MRL 256-dim cut. 각각이 단독으로도 의미 있지만, 조합되면서 운영 비용 곡선을 완전히 새로 그렸다.
주목할 점: 품질 손실 없이 비용·지연을 깎는 데 성공했다는 것. 대부분의 efficiency 작업은 약간의 정확도를 희생하지만, 여기서는 recall 0.95+를 유지하면서 latency · CPU · storage를 동시에 압축.
도입 순서는 shard k 튜닝 → 양자화 → MRL. 앞쪽이 변경 비용이 낮고 효과 측정도 쉽다.
가장 반직관적 부분 — "ANN 자체 최적화보다 ANN으로 들어가는 후보를 줄이는 게 압도적으로 효과적이었다". 사람들은 흔히 HNSW 파라미터 튜닝에 매달리지만, 실제 운영에서는 boolean 사전 필터의 설계가 latency · 비용에 훨씬 큰 영향을 미친다. index를 vertical/지리 기준으로 분리하는 데 시간을 더 쓰는 게 ROI가 훨씬 높음.
Uber 내부 코드는 공개되지 않았으니, 아이디어를 PyTorch + Sentence-Transformers + FAISS로 재구성한 참고 구현. 실제 배포에서는 Lucene Plus / HNSW 인덱스, gRPC 서빙, 모니터링 등이 더 붙는다.
import torch import torch.nn as nn from transformers import AutoModel, AutoTokenizer MRL_DIMS = [128, 256, 512, 768, 1024, 1280, 1536] class EncoderTower(nn.Module): """쿼리·문서 양쪽이 공유하는 인코더 — 같은 임베딩 공간 보장.""" def __init__(self, model_name: str = "Qwen/Qwen-Embedding"): super().__init__() self.backbone = AutoModel.from_pretrained(model_name) self.proj = nn.Linear(self.backbone.config.hidden_size, max(MRL_DIMS)) def forward(self, input_ids, attention_mask): h = self.backbone(input_ids, attention_mask=attention_mask).last_hidden_state # pool: weighted mean over tokens mask = attention_mask.unsqueeze(-1).float() pooled = (h * mask).sum(1) / mask.sum(1).clamp(min=1e-6) z = self.proj(pooled) # (B, 1536) return nn.functional.normalize(z, dim=-1) # L2 정규화 — cosine sim 위해 def slice_mrl(z: torch.Tensor, dim: int) -> torch.Tensor: """주어진 차원으로 잘라서 다시 L2 정규화.""" return nn.functional.normalize(z[..., :dim], dim=-1)
def mrl_info_nce(q: torch.Tensor, d_pos: torch.Tensor, d_neg: torch.Tensor, temperature: float = 0.05) -> torch.Tensor: """모든 MRL 차원 cut에 대해 InfoNCE를 동등 가중치로 합산. q, d_pos: (B, D_max) d_neg: (B, K, D_max) — in-batch + hard negatives """ total = 0.0 for dim in MRL_DIMS: qc = slice_mrl(q, dim) # (B, dim) dp = slice_mrl(d_pos, dim) # (B, dim) dn = slice_mrl(d_neg, dim) # (B, K, dim) # positive: q · d_pos, negatives: q · each d_neg pos_sim = (qc * dp).sum(-1, keepdim=True) # (B, 1) neg_sim = torch.einsum("bd,bkd->bk", qc, dn) # (B, K) logits = torch.cat([pos_sim, neg_sim], dim=-1) / temperature target = torch.zeros(logits.size(0), dtype=torch.long, device=logits.device) total += nn.functional.cross_entropy(logits, target) return total / len(MRL_DIMS)
import faiss import numpy as np def build_index(docs: list[dict], encoder: EncoderTower, dim: int = 256, hnsw_m: int = 32) -> faiss.Index: """스케줄 작업으로 격주 실행되는 인덱스 빌드 — Blue/Green 컬럼.""" # 1) 배치 임베딩 embeds = [] for batch in chunks(docs, 1024): toks = tokenize([d["text"] for d in batch]) with torch.no_grad(): z = encoder(**toks) embeds.append(slice_mrl(z, dim).cpu().numpy()) vecs = np.vstack(embeds).astype("float32") # 2) int7 양자화 — latency 절반 quantizer = faiss.ScalarQuantizer(dim, faiss.ScalarQuantizer.QT_7bit) index = faiss.IndexHNSWSQ(dim, faiss.ScalarQuantizer.QT_7bit, hnsw_m) index.train(vecs) index.add(vecs) # 3) Boolean 필터용 메타데이터 별도 저장 meta = {d["id"]: {"hexagon": d["hexagon"], "city_id": d["city_id"], "doc_type": d["doc_type"]} for d in docs} return index, meta # 검증 게이트 3종 — 배포 전 통과 필수 def validate_index(new_idx, old_idx, sample_queries): assert new_idx.ntotal >= old_idx.ntotal * 0.99 # completeness assert schema_compat(new_idx, old_idx) # backward-compatibility assert recall_regression(new_idx, old_idx, sample_queries) > 0.98 # correctness
QT_7bit가 성능 핵심.def search(query: str, user_ctx: dict, encoder: EncoderTower, index: faiss.Index, meta: dict, reranker, k: int = 200) -> list: # 1) 쿼리 임베딩 (online, ms 단위) toks = tokenize([query]) with torch.no_grad(): q = encoder(**toks) q256 = slice_mrl(q, 256).cpu().numpy().astype("float32") # 2) Boolean pre-filter — billion → 1M 미만으로 먼저 깎는다 allowed_ids = { doc_id for doc_id, m in meta.items() if m["hexagon"] in user_ctx["reachable_hexagons"] and m["city_id"] == user_ctx["city_id"] and m["doc_type"] in user_ctx["allowed_types"] } selector = faiss.IDSelectorArray(list(allowed_ids)) params = faiss.SearchParametersHNSW(sel=selector, efSearch=128) # 3) ANN — 깎인 후보들 안에서만 D, I = index.search(q256, k, params=params) # 4) Micro re-rank — 작은 NN으로 top-K 정밀 정렬 candidates = [{"id": int(i), "score": float(d)} for i, d in zip(I[0], D[0])] return reranker(query, candidates, user_ctx)[:20]
IDSelectorArray로 HNSW 안에서 제외 처리.import deepspeed from torch.utils.data import DataLoader def train(encoder: EncoderTower, dataset, config_path: str): """수억 샘플 학습 — ZeRO-3로 모델 파라미터 분산.""" model_engine, optimizer, _, _ = deepspeed.initialize( model=encoder, config=config_path, # ZeRO-3 + bf16 + grad checkpointing ) loader = DataLoader(dataset, batch_size=256, num_workers=8, pin_memory=True) for step, batch in enumerate(loader): q = model_engine(**batch["query"]) # (B, D_max) dp = model_engine(**batch["pos_doc"]) # (B, D_max) dn = model_engine(**batch["neg_docs"]) # (B, K, D_max) loss = mrl_info_nce(q, dp, dn, temperature=0.05) model_engine.backward(loss) model_engine.step() if step % 100 == 0: log(f"step={step} loss={loss.item():.4f}")
위 코드는 핵심 아이디어 재구성이다. 실제 Uber 배포에서는 Lucene Plus의 분산 인덱싱, 컨테이너 오케스트레이션, gRPC 서빙 레이어, A/B 실험 프레임워크, 모니터링·alert 인프라가 모두 붙는다. 단일 모델로 모든 vertical(레스토랑·grocery·retail)과 모든 시장을 커버한 것이 운영 관점에서 가장 큰 수확이라고 저자들은 강조한다.