임베딩 검색 구현기 - overview
2026.05.09
Personas — 임베딩 기반 페르소나 검색 시스템 구현 회고
한 줄 요약
NVIDIA Nemotron 한국어 페르소나 데이터셋 100만 건을 OpenAI 임베딩으로 벡터화하고, pgvector + HNSW로 의미 검색 시스템을 구축한 뒤, NestJS API + Next.js 프론트로 광고 도메인 페르소나 검색기를 만든 프로젝트.
만든 것
- 배포: https://personas.eunoh.top (API), https://eunoh.top/tests/personas (UI)
- 관측성: https://grafana.eunoh.top (Cloudflare Zero Trust로 보호)
최종 스택
[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가지 멘탈 모델의 진화다. 책으로 못 배우는 영역들이라, 회사에서 비슷한 시스템 만들 때 시작선이 달라질 것.