pgvector + Gemini Embedding으로 "AI가 사람을 대체할 수 있을까"를 검색하면 영어 HN 글이 나오게 만든 이야기
왜 시맨틱 검색이 필요했나
매일 LinkedIn, X, HN, YouTube에서 크롤링한 콘텐츠가 1,400개를 넘어섰다. LIKE '%AI%'로는 한계가 명확했다.
"AI가 사람을 대체할 수 있을까" → 검색 결과: 0건
키워드 검색은 "AI"라는 단어가 있어야만 찾는다. "인공지능이 일자리에 미치는 영향"이나 "Will machines replace humans?"는 의미적으로 동일한데 키워드가 다르면 못 찾는다.
시맨틱 검색은 의미를 벡터로 변환해서 비교한다. "스타트업 창업 조언"을 검색하면 "스타트업에서 C레벨로 보낸 1주일의 회고"가 나온다. 키워드가 하나도 겹치지 않는데.
기술 선택: 왜 pgvector + Gemini인가
벡터 DB 선택
| 옵션 | 장점 | 단점 |
|---|---|---|
| Pinecone | 관리형, 스케일 | 유료, 외부 의존성 |
| Milvus | 고성능 | 별도 인프라 |
| pgvector | PostgreSQL 확장, 기존 DB 활용 | 대규모엔 한계 |
1인 개발자에게 답은 명확했다. 이미 PostgreSQL을 쓰고 있으니 pgvector 확장만 설치하면 된다. 별도 벡터 DB 운영 부담 제로.
CREATE EXTENSION vector;
ALTER TABLE contents ADD COLUMN embedding vector(1024);
CREATE INDEX ON contents USING hnsw (embedding vector_cosine_ops);
3줄이면 끝. 기존 테이블에 벡터 컬럼 추가 + HNSW 인덱스까지.
임베딩 모델 선택
Gemini의 gemini-embedding-001을 선택한 이유:
- 다국어 지원 — 한국어 LinkedIn + 영어 HN을 같은 벡터 공간에 넣을 수 있다
- 1024차원 — OpenAI text-embedding-3-small(1536)보다 작아서 저장/검색 효율적
- 무료 티어 — 시작할 때 돈 안 들고, 상용 키로 전환도 간단
Provider 추상화 패턴으로 나중에 OpenAI나 다른 모델로 갈아타기도 쉽게 만들었다:
class EmbeddingProvider(ABC):
@abstractmethod
async def generate(self, text: str) -> list[float]: ...
class GeminiProvider(EmbeddingProvider): ...
class GLMProvider(EmbeddingProvider): ...
구현: 의외로 심플한 핵심 로직
크롤 시 자동 임베딩
크롤러가 새 콘텐츠를 저장할 때 fire-and-forget으로 임베딩을 생성한다:
# crawler_service.py
content = Content(title=title, content=text, url=url, ...)
db.add(content)
await db.flush() # ID 확보
# Fire-and-forget 임베딩 생성
asyncio.create_task(_generate_embedding_safe(content))
임베딩 생성이 실패해도 콘텐츠 저장에는 영향 없다. 나중에 backfill_embeddings.py로 채울 수 있으니까.
검색 API: 3가지 모드
GET /api/contents/search?q=AI agent&mode=hybrid&limit=5
| 모드 | 방식 | 언제 쓰나 |
|---|---|---|
keyword |
ILIKE | 정확한 단어 검색 ("Playwright", "Claude") |
semantic |
cosine distance | 의미 기반 탐색 ("AI가 사람을 대체할까") |
hybrid |
keyword + semantic 결합 | 기본값 — 둘 다 커버 |
운영 배포에서 만난 5가지 함정
여기서부터가 진짜 이야기다. 로컬에서 완벽하게 동작하던 시맨틱 검색이 운영에 나가면서 5가지 함정을 만났다.
함정 1: Dockerfile 의존성 드리프트
# 이전: 패키지를 하나하나 나열 (24줄)
RUN pip install --no-cache-dir \
fastapi==0.109.0 \
sqlalchemy==2.0.25 \
# ... pgvector 빠짐! google-genai 빠짐!
# 이후: 한 줄로 해결
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
교훈: 두 곳에 같은 정보를 적는 구조는 반드시 불일치로 이어진다.
함정 2: 환경변수 캐시
.env에서 Gemini API 키를 무료 → 상용으로 교체했다. 백필 스크립트는 정상 동작. 그런데 서버 검색은 429 에러.
# 백필 스크립트: .env 직접 읽음 → 상용 키 사용 → OK
# 서버 프로세스: 시작 시점 값 캐시 → 무료 키 사용 → 쿼터 초과!
교훈: .env 변경 후 서버 재시작 필수. Docker는 --force-recreate.
함정 3: 운영 DB에 컬럼이 없다
v1.0.3 배포 후 "column contents.embedding does not exist". 당연하지 — Alembic 마이그레이션을 안 돌렸으니까.
# deploy.sh는 코드만 배포한다. 스키마 변경은 별도!
docker exec pgvector-postgres-1 psql -U pguser -d contents_hub -c \
"CREATE EXTENSION IF NOT EXISTS vector;
ALTER TABLE contents ADD COLUMN IF NOT EXISTS embedding vector(1024);"
교훈: 대부분의 배포는 코드만 변경. 스키마 변경은 체크리스트로 관리하는 게 ROI가 높다.
함정 4: Alembic이 Docker에서 안 돌아간다
alembic.ini에 localhost:5432가 하드코딩되어 있는데, Docker 컨테이너 안에서는 DB가 pgvector-postgres-1:5432다.
# alembic/env.py에 3줄 추가로 해결
database_url = os.getenv("DATABASE_URL")
if database_url:
config.set_main_option("sqlalchemy.url", database_url)
함정 5: 불필요한 재임베딩
API 키를 바꿨으니 임베딩도 다시 해야 하지 않나? 아니다.
| 변경 | 재임베딩 필요? |
|---|---|
| API 키 교체 (같은 모델) | NO |
| 서버 재시작 | NO |
| 모델 변경 (001 → 002) | YES |
| 차원 변경 (1024 → 768) | YES |
같은 모델이면 같은 입력에 같은 벡터를 출력한다. 키는 인증 수단일 뿐.
결과: 크로스랭귀지 검색이 된다
1,409개 콘텐츠에 임베딩을 넣고 테스트한 결과:
영어 쿼리 → 영어 결과 (similarity 0.84~0.86)
"AI agent" → AOrchestra, AgentIF-OneDay, daVinci-Agency...
한국어 쿼리 → 한국어+영어 결과 (similarity 0.78~0.80)
"스타트업 창업 조언" → 스타트업 C레벨 회고, YC AI-Native Agency...
한영 혼합 쿼리도 동작 (similarity 0.81~0.83)
"LLM fine-tuning 방법" → Scaling Data Mixing, Good SFT, CoBA-RL...
Gemini 임베딩의 다국어 성능이 꽤 좋다. 한국어로 검색해도 의미적으로 관련 있는 영어 논문이 나온다.
비용
| 항목 | 비용 |
|---|---|
| pgvector | 무료 (PostgreSQL 확장) |
| Gemini Embedding API | 무료 티어로 시작 → 상용 $0.006/1K tokens |
| 인프라 추가 | 없음 (기존 PostgreSQL + VM) |
1,409건 백필 비용: 약 $0.1 미만. 일반 사이드 프로젝트에서 비용 걱정할 수준이 아니다.
정리
1인 개발자가 시맨틱 검색을 구축하는 건 생각보다 쉽다:
- pgvector — 기존 PostgreSQL에 확장만 추가
- Gemini Embedding — 다국어 지원 + 무료 티어
- Provider 추상화 — 나중에 모델 교체 대비
- 크롤 시 자동 임베딩 — fire-and-forget 패턴
- 3가지 검색 모드 — keyword/semantic/hybrid
운영 배포에서 만나는 함정들(Dockerfile 드리프트, 환경변수 캐시, DB 마이그레이션)이 더 어려웠다. 하지만 한번 해결하고 문서화하면 다음부터는 체크리스트 따라가면 된다.
전체 코드는 1,400줄 미만이다. 임베딩 서비스(170줄) + 검색 API(170줄) + 백필 스크립트(130줄). 나머지는 기존 크롤러/다이제스트 코드에 자연스럽게 녹아든다.
Contents Hub — 정보 구독, 이제 한 곳에서 끝냅니다.