notebook
Uber Blog · 2026.04 학습 노트 · 읽는 시간 13분

Uber Delivery
Search Platform

"Uber Eats 사용자는 'gf pizza'를 검색하고 'soda'를 'soft drink'로 바꿔 찾는다. Lexical 매칭으로는 답을 찾을 수 없는 — billion-scale의 의미 검색을 어떻게 풀었는가."

저자 Nagar et al· 5명
발행 Uber Blog· 2026.04
코어 Two-Tower+ MRL
백본 QWEN· 단일 모델
작성 2026.05.20
01

한 줄로 이해하기

Uber Eats 검색의 본질은 의미 매칭이다. "soda"를 입력해도 "soft drink"가 잡혀야 하고, "gf pizza"가 gluten-free임을 알아야 한다. Uber는 QWEN 백본의 Two-Tower 시맨틱 검색을 Matryoshka 임베딩 · Lucene Plus · int7 양자화 · 사전 필터링으로 묶어 billion-scale 카탈로그를 밀리초 단위로 푼다.

i

문제 — Lexical 매칭의 한계

전통적 키워드 매칭은 synonym · typo · shorthand · 다국어 혼용에서 실패한다. "soda" vs "soft drink", "gf pizza" → gluten-free 같은 패턴은 키워드 겹침이 없어 검색 결과가 비어버린다.

ii

Two-Tower 아키텍처

쿼리와 문서를 같은 임베딩 공간으로 매핑하되, 계산은 분리. Query Tower는 온라인 실시간(사용자가 입력할 때마다), Document Tower는 오프라인 배치(스케줄 작업). 두 경로를 분리해 독립 최적화 가능.

iii

QWEN 단일 백본

두 타워의 백본 모두 QWEN LLM. world knowledge + cross-lingual 능력을 가져오고 Uber Eats 사내 데이터로 fine-tuning. 모든 vertical(레스토랑·grocery·retail), 모든 시장에 단일 모델로 임베딩 — 운영 단순화.

iv

Matryoshka 임베딩 — 한 모델, 7개 차원

MRL-기반 InfoNCE loss로 학습. 같은 모델이 [128, 256, 512, 768, 1024, 1280, 1536] 7개 차원을 동시에 출력. 256-dim에서 저장공간 -50%, 품질 손실 <0.3%. 비용/품질 트레이드오프 곡선 위 sweet spot.

v

Lucene Plus + HNSW + int7 양자화

Lucene Plus를 인덱스 기반으로 — vertical별 분리(OFD/GR). HNSW로 ANN 검색, int7 양자화로 latency 50% 추가 절감(recall 0.95+ 유지). float32와 int8 듀얼 저장도 지원.

vi

Boolean Pre-filter — 99%+ 후보 제거

ANN 검색 전에 hexagon(지리) · city_id · doc_type · fulfillment_types로 boolean 필터링. 수십억 후보 중 1% 미만만 ANN으로 넘기므로 billion-scale도 ms 단위 응답 가능. 가장 중요한 비용 절감 트릭.

02
Architecture & Wins

그림으로 보는 구조

01 · 아키텍처Two-Tower 구조

쿼리와 문서를 같은 임베딩 공간으로 사상하되 계산 경로를 둘로 분리. Query Tower는 사용자 요청마다 ms 단위로 인코딩, Document Tower는 격주 스케줄로 카탈로그 전체를 한 번에 인코딩.

분리된 덕분에 — query 측은 latency 최적화, document 측은 throughput · 비용 최적화로 독립적으로 진화 가능. 같은 백본(QWEN)을 사용하므로 공간 일관성은 자동 보장.

왜 분리하나

document 임베딩을 매 요청마다 다시 계산하면 비용이 폭주. 카탈로그는 분 단위로 변하지 않으니 배치로 충분. 사용자 쿼리만 실시간.

Online · query
user queryreal-time
QWEN encoderfine-tuned
q ∈ ℝᵈMRL · 7 cuts
Offline · document
catalogstore · dish · item
QWEN encodersame model
d ∈ ℝᵈfeature store
ANN matching · Lucene Plus + HNSW

02 · 임베딩Matryoshka 임베딩

MRL(Matryoshka Representation Learning)은 한 모델이 여러 차원의 임베딩을 동시에 학습하도록 만든다. InfoNCE loss를 7개 차원 cut 각각에 똑같은 가중치로 적용해 어느 차원으로 잘라도 의미가 보존되도록.

결과: 검색 latency와 저장 비용이 가장 큰 256-dim cut을 운영에 사용해도 품질 손실 0.3% 미만. 나머지 차원들은 정확도가 더 중요한 re-ranking 단계용으로 활용 가능.

실용 가치

"품질 vs 비용" 곡선을 한 모델로 모두 커버. 별도 dimensionality reduction (PCA 등) 불필요. 차원별 retraining도 불필요.

128
tiny · re-rank
256
prod · −50%
512
balanced
768
high recall
1024
research
1280
research
1536
full
256-dim cut · 품질 손실 <0.3% · 저장 비용 −50%

03 · 파이프라인Retrieval Funnel

billion-scale 카탈로그를 ms 단위로 푸는 비결은 ANN 전에 후보를 99% 이상 깎는 것. 지리적으로 갈 수 없는 음식점, vertical 불일치, fulfillment 타입 불일치는 boolean 필터로 즉시 제거.

남은 후보에 한해서만 HNSW 그래프 위에서 ANN, 그 결과를 작은 신경망으로 micro-re-rank. 각 단계가 다음 단계의 후보를 한 자릿수씩 줄이는 깔때기 구조.

핵심 교훈

"ANN을 빠르게"보다 "ANN에 넘어가는 후보를 줄이는" 것이 훨씬 효과적. boolean pre-filter는 무료에 가깝다 — index 설계만 잘 하면.

전체 카탈로그store · dish · item
~10⁹
Boolean pre-filterhexagon · city · doc_type
~10⁵ (−99.99%)
ANN on HNSWint7 · 256-dim
~200
Micro re-rankcompact NN
~20 (응답)

04 · 결과측정된 개선

세 개의 독립적 최적화가 누적으로 효과를 낸다 — shard k 튜닝 (1200→200), int7 양자화, MRL 256-dim cut. 각각이 단독으로도 의미 있지만, 조합되면서 운영 비용 곡선을 완전히 새로 그렸다.

주목할 점: 품질 손실 없이 비용·지연을 깎는 데 성공했다는 것. 대부분의 efficiency 작업은 약간의 정확도를 희생하지만, 여기서는 recall 0.95+를 유지하면서 latency · CPU · storage를 동시에 압축.

실전 우선순위

도입 순서는 shard k 튜닝 → 양자화 → MRL. 앞쪽이 변경 비용이 낮고 효과 측정도 쉽다.

Shard k 1200 → 200
latency −34%
Shard k CPU
CPU −17%
int7 양자화 vs fp32
latency −50%+
MRL 256-dim vs 1536-dim
storage −50%
품질 손실 recall
<0.3%

가장 반직관적 부분 — "ANN 자체 최적화보다 ANN으로 들어가는 후보를 줄이는 게 압도적으로 효과적이었다". 사람들은 흔히 HNSW 파라미터 튜닝에 매달리지만, 실제 운영에서는 boolean 사전 필터의 설계가 latency · 비용에 훨씬 큰 영향을 미친다. index를 vertical/지리 기준으로 분리하는 데 시간을 더 쓰는 게 ROI가 훨씬 높음.

그래서 — 이 패턴은 언제 적합한가?

적합한 시나리오

  • 의미 매칭이 중요한 검색 (synonym · typo · 다국어)
  • 대규모 카탈로그 (수백만~수십억 문서)
  • 지리·카테고리 등 강한 사전 필터가 존재
  • 카탈로그 갱신이 분 단위가 아닌 시간/일 단위
  • 비용에 민감 (저장 + 추론 둘 다)

적합하지 않은 경우

  • 키워드 정확 매칭만으로 충분 (코드 검색, 정형 데이터)
  • 카탈로그가 실시간으로 변함 (재고 변동 등 분 단위)
  • 강한 사전 필터가 없어 ANN이 전체 카탈로그를 봐야 함
  • 모델 학습 인프라가 없거나 fine-tuning 데이터 부족
  • 응답 일관성 · 디버깅 가능성이 latency보다 중요
03
Reference Implementation

실전 코드

Uber 내부 코드는 공개되지 않았으니, 아이디어를 PyTorch + Sentence-Transformers + FAISS로 재구성한 참고 구현. 실제 배포에서는 Lucene Plus / HNSW 인덱스, gRPC 서빙, 모니터링 등이 더 붙는다.

01 · Two-Tower 모델 (QWEN 백본 + MRL 출력)

model
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)
포인트. 쿼리와 문서 양쪽이 같은 인코더를 쓰는 게 핵심 — 임베딩 공간 일관성 보장. MRL은 단순히 "큰 임베딩을 앞에서 잘라쓴다"가 아니라, 학습 시 모든 차원 cut에 loss를 거는 것이 트릭.

02 · MRL InfoNCE Loss

training
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)
포인트. 핵심은 모든 차원에 동등 가중치. 만약 큰 차원에 더 큰 weight를 주면 작은 차원 cut의 품질이 무너진다. Uber는 7개 cut 모두 1/7씩 — 그래서 256-dim도 1536-dim과 거의 동등한 품질을 낸다.

03 · Offline Document Indexing (배치)

offline
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
포인트. Uber는 blue/green을 별도 인덱스가 아닌 한 인덱스 안의 컬럼으로 운영해 저장 -50%. 대신 세 가지 검증 게이트(완전성·하위호환·정확성)로 single-index 리스크를 방어. QT_7bit가 성능 핵심.

04 · Online Retrieval (pre-filter + ANN + re-rank)

online
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]
포인트. 단계 순서가 핵심 — pre-filter → ANN → re-rank. 순서를 바꾸면 (예: ANN 먼저, filter 나중) billion-scale에서 latency가 즉시 1000배 차이난다. IDSelectorArray로 HNSW 안에서 제외 처리.

05 · 학습 파이프라인 (DDP + DeepSpeed ZeRO-3)

training
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}")
포인트. 수억 단위 학습 데이터 + 큰 LLM 백본은 단일 GPU 불가 — DDP + DeepSpeed ZeRO-3로 파라미터·옵티마이저 상태·gradient를 모두 분산. bf16 + gradient checkpointing은 메모리 추가 절약. Hard negatives의 품질이 학습 결과를 크게 좌우한다.

위 코드는 핵심 아이디어 재구성이다. 실제 Uber 배포에서는 Lucene Plus의 분산 인덱싱, 컨테이너 오케스트레이션, gRPC 서빙 레이어, A/B 실험 프레임워크, 모니터링·alert 인프라가 모두 붙는다. 단일 모델로 모든 vertical(레스토랑·grocery·retail)과 모든 시장을 커버한 것이 운영 관점에서 가장 큰 수확이라고 저자들은 강조한다.