임베딩 검색 구현기 - overview

2026.05.09

Personas — 임베딩 기반 페르소나 검색 시스템 구현 회고

한 줄 요약

NVIDIA Nemotron 한국어 페르소나 데이터셋 100만 건을 OpenAI 임베딩으로 벡터화하고, pgvector + HNSW로 의미 검색 시스템을 구축한 뒤, NestJS API + Next.js 프론트로 광고 도메인 페르소나 검색기를 만든 프로젝트.

만든 것

최종 스택

[Next.js 프론트] 
↓ 
[NestJS API (홈 우분투 서버)] 
↓ 
├─→ [OpenAI text-embedding-3-small + gpt-4o-mini (HyDE)]
├─→ [Redis: HyDE 캐시 + 임베딩 캐시 (2단)] 
└─→ [PostgreSQL + pgvector + HNSW (100만 행)]

[Cloudflare Tunnel] → 외부 노출 (TLS·라우팅·헬스체크 통합) 
[OTel + LGTM 스택] → 관측성 (api → grafana로 시각화)

숫자로 보는 결과

  • 데이터: 100만 행 × 26 컬럼 (자연어 10 + 카테고리 9 + 리스트 2 + 메타)
  • 임베딩 비용: 약 $16 (OpenAI Batch API, 50% 할인 적용)
  • 임베딩 차원: 1536 (text-embedding-3-small)
  • HNSW 인덱스: m=16, ef_construction=64, ef_search=80 → 약 6GB 디스크
  • 검색 응답: 콜드 1243ms / 웜 184ms / API 전체 ~400ms (HyDE LLM 포함, 캐시 미스 기준)
  • 매칭 점수: 단순 쿼리 임베딩 ~0.42 → HyDE 적용 후 0.5+

학습 목표 vs 실제 학습한 것

시작할 때 적었던 것

  • HuggingFace에서 큰 데이터를 받아 뭔가 만들어보고 싶다
  • 대량 데이터 다뤄보고 싶다
  • 임베딩과 벡터 검색을 직접 해보고 싶다
  • 관측성도 챙겨보고 싶다

끝나고 보니 실제로 배운 것

  • 데이터 탐색 워크플로우 (5단계 체크리스트로 표준화)
  • 임베딩의 본질 — "특정 모델만의 좌표계"라는 멘탈 모델
  • 임베딩 입력 텍스트 설계의 비중 (모델보다 입력이 70%)
  • 쿼리 측 augmentation (HyDE) — 0.42 → 0.5+로 끌어올린 경험
  • OpenAI Batch API의 운영 함정 (enqueue 한도, 등록-후-즉시-실패, 체크포인팅 필요성)
  • pgvector 옵티마이저의 path 선택 (selectivity 기반, ef_search의 진짜 역할)
  • COPY로 100만 건 적재 (라운드트립 vs 단일 스트림)
  • Prisma + pg.Pool 하이브리드 패턴 (vector 타입과 거리 연산자가 ORM 추상화 밖)
  • 관측성의 운영 디테일 — rate()의 sample 부족 문제, "비어 보임"과 "0"의 차이, 비율 기반 알림의 트래픽 가드, severity 라우팅으로 dev/prod 잡음 분리
  • Cloudflare Tunnel의 진짜 사용법 (앞단 reverse proxy 불필요)

의도하지 않게 얻은 것

  • Redis 캐시 설계 (2단 캐시, fallible하게 다루기, sha256 키 + 모델명 prefix)
  • 며칠짜리 비동기 작업의 구조화 (체크포인팅, 일시 오류 재시도, 재개 가능성)
  • 옵티마이저를 의심하지 않고 EXPLAIN으로 검증하는 습관
  • 한국 IP에서의 OpenAI Batch API enqueue 패턴 — Tier 2 한계 안에서 throughput 짜내기

시리즈 글 가이드

각 글이 다루는 주제와, 어떤 상황에서 다시 펼쳐보면 좋을지. 타이틀 클릭시 이동됨.

01. dataset

  • 보러가기
  • 다루는 것: HuggingFace datasets 라이브러리, parquet의 컬럼 지향 의미, 100만 행 탐색 5단계 체크리스트, 1만→100만 스케일업 워크플로우
  • 다시 볼 때: 새 데이터셋 받았을 때, 데이터 탐색 표준 절차 떠올려야 할 때

02. embedding

  • 보러가기
  • 다루는 것: 임베딩의 본질("좌표계"), 모델 선택 트레이드오프(API vs 로컬, small vs large), 입력 텍스트 설계, HyDE 패턴과 2단 캐시, OpenAI Batch API 함정, 검증 패턴
  • 다시 볼 때: 회사에서 임베딩/RAG 시스템 도입 결정할 때 — 가장 먼저 펼쳐볼 글
  • 비중 큰 섹션: HyDE (0.42 → 0.5+로 끌어올린 부분)

03. pgvector + HNSW

  • 보러가기
  • 다루는 것: 호스팅 결정(Supabase/Railway/로컬 비교), HNSW 빌드 파라미터, 옵티마이저 path 선택 문제 (selectivity 기반), 콜드/웜 캐시, COPY 적재, Prisma + pg.Pool 하이브리드
  • 다시 볼 때: 벡터 검색을 Postgres에 얹을 때, "인덱스가 안 쓰이는 것 같은데?" 의심이 들 때
  • 비중 큰 섹션: 옵티마이저 path 인과 분리

04. observability

  • 보러가기
  • 다루는 것: LGTM 스택 + OTel, Grafana request rate 빈 결과 진단, 비율 기반 알림의 트래픽 가드, severity 라우팅
  • 다시 볼 때: 새 서비스에 관측성 처음 붙일 때, 알림 잡음으로 답답할 때
  • 비중 큰 섹션: rate()와 [$__rate_interval], or vector(0) fallback, 알림 라우팅

05. pitfalls

  • 보러가기
  • 다루는 것: 위 4개 영역에서 부딪힌 함정들의 증상/원인/해결/메모 형식 모음
  • 다시 볼 때: "어디서 봤더라" 싶은 증상이 떠오를 때 — 키워드 검색용 인덱스

멘탈 모델의 진화

이 프로젝트로 5가지 영역의 멘탈 모델이 한 단계씩 진화했다. 각 글에 자세히 적었지만 한 줄 요약하면:

영역시작
데이터셋"HF에서 받는 거"5단계 체크리스트로 표준화
임베딩"1536차원? 그게 뭔데""특정 모델만의 좌표계, 입력 텍스트 설계가 70%"
pgvector"pg_trgm처럼 인덱스 만들면 끝""필터 selectivity가 path 결정, ef_search는 path가 아니라 후보 풀"
관측성"Slack 알림은 있는데 컨텍스트 없음""rate()/window/sample/fallback의 상호작용, 라우팅으로 잡음 분리"
운영"외부 노출하려면 nginx""cloudflared 단독으로 충분, ufw + Tailscale 인터페이스"

다음 단계 후보

회고를 위한 멈춤이지 종료는 아니다. 이어서 가능한 트랙들:

  • 검색 결과 캐시 (Redis 학습 심화) — (query+filters) 해시 → 결과 5분 TTL
  • 광고 도메인 워크플로우 — "캠페인 페르소나 N명 셋 만들기 → CSV/PDF 내보내기", LLM이 카피 검수
  • Rate limiting — IP별 분당 요청 제한
  • 관측성 확장 — 임베딩/DB/Redis/비즈니스 메트릭 단계적 추가
  • 실험 시나리오 — 회사 동료/클라이언트에게 personas.eunoh.top 던져 의견 받기

회고를 마치며

처음에 "허깅페이스에서 큰 데이터로 뭘 해본 게 처음"이라고 시작했다. 끝에 와서는 데이터 다운로드부터 임베딩 파이프라인, 벡터 인덱스 튜닝, 캐시 설계, API/UI, 외부 노출, 관측성까지 한 번에 통과했다.

가장 큰 자산은 코드가 아니라 **"부딪혀봐야만 알 수 있는 것들의 목록"**과 그 5가지 멘탈 모델의 진화다. 책으로 못 배우는 영역들이라, 회사에서 비슷한 시스템 만들 때 시작선이 달라질 것.