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을 선택한 이유:

  1. 다국어 지원 — 한국어 LinkedIn + 영어 HN을 같은 벡터 공간에 넣을 수 있다
  2. 1024차원 — OpenAI text-embedding-3-small(1536)보다 작아서 저장/검색 효율적
  3. 무료 티어 — 시작할 때 돈 안 들고, 상용 키로 전환도 간단

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.inilocalhost: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인 개발자가 시맨틱 검색을 구축하는 건 생각보다 쉽다:

  1. pgvector — 기존 PostgreSQL에 확장만 추가
  2. Gemini Embedding — 다국어 지원 + 무료 티어
  3. Provider 추상화 — 나중에 모델 교체 대비
  4. 크롤 시 자동 임베딩 — fire-and-forget 패턴
  5. 3가지 검색 모드 — keyword/semantic/hybrid

운영 배포에서 만나는 함정들(Dockerfile 드리프트, 환경변수 캐시, DB 마이그레이션)이 더 어려웠다. 하지만 한번 해결하고 문서화하면 다음부터는 체크리스트 따라가면 된다.

전체 코드는 1,400줄 미만이다. 임베딩 서비스(170줄) + 검색 API(170줄) + 백필 스크립트(130줄). 나머지는 기존 크롤러/다이제스트 코드에 자연스럽게 녹아든다.


Contents Hub — 정보 구독, 이제 한 곳에서 끝냅니다.