Back

내 앱에 AI 검색 달기: 벡터 검색, 하이브리드 검색, 시맨틱 랭킹을 밑바닥부터

유저가 "로그인 안 될 때 그 무한루프 도는 거 해결법"이라고 검색하는데, 검색 결과가 0건이에요. "인증 토큰 만료 문제 해결"이라는 딱 맞는 문서가 있는데 키워드가 안 겹쳐서 못 찾는 거예요.

전통적인 검색의 근본적 한계가 이거예요. 단어를 매칭하지, 의미를 매칭하지 않아요. 근데 2026년에 유저들은 의도를 알아듣는 검색을 기대하고 있어요.

좋은 소식은, AI 검색이 더 이상 박사 과정 프로젝트가 아니라는 거예요. 요즘 임베딩 모델이랑 벡터 DB, 그리고 패턴 몇 개만 조합하면 유저가 뭘 뜻하는지를 진짜로 알아듣는 검색을 만들 수 있어요.

이 글에서는 프로덕션급 AI 검색을 처음부터 같이 만들어볼 거예요. 벡터 검색 기초부터 시작해서, 대부분의 앱에 딱인 하이브리드 검색, 정밀도를 확 올려주는 시맨틱 리랭킹, 그리고 다른 튜토리얼에선 안 알려주는 프로덕션 함정까지 전부 다뤄요. 바로 쓸 수 있는 TypeScript 코드도 같이요.

검색의 3세대

코드 전에, 지금 우리가 어디에 있고 각 세대가 왜 존재하는지 이해하고 갈게요.

1세대: 키워드 검색 (BM25/TF-IDF)

아직도 대부분의 앱이 쓰는 방식이에요. PostgreSQL의 tsvector, Elasticsearch 기본 모드, 심지어 SQL LIKE 쿼리까지.

-- 클래식한 접근법 SELECT * FROM articles WHERE to_tsvector('english', title || ' ' || body) @@ to_tsquery('english', 'authentication & token & expiry');

동작 원리: 검색어가 문서에 몇 번 나오는지 세고, 희소성으로 가중치 매기고(IDF), 관련도 점수로 정렬.

잘 되는 경우:

  • 정확한 용어 매칭 ("ERROR 0x80070005")
  • 특정 문서 이름으로 찾기
  • 불린 연산 쿼리
  • 임베딩 모델이 모를 수 있는 도메인 전문 용어

안 되는 경우:

  • 동의어 ("자동차" vs "차량" vs "승용차")
  • 의도 파악 ("사이트 빠르게 하는 법" → "웹 성능 최적화"랑 매칭돼야 함)
  • 오타
  • 다국어 쿼리

2세대: 벡터 검색 (시맨틱)

벡터 검색은 텍스트를 의미를 담은 숫자 표현(임베딩)으로 바꿔요. 비슷한 개념은 사용한 단어와 상관없이 벡터 공간에서 가까이 놓이게 돼요.

// "로그인 문제 해결"과 "인증 에러 수정"이 // 가까운 벡터로 변환됨 const embedding1 = await embed("로그인 문제 해결"); const embedding2 = await embed("인증 에러 수정"); cosineSimilarity(embedding1, embedding2); // ~0.92 (매우 유사!)

동작 원리: 임베딩 모델(OpenAI text-embedding-3-small이나 오픈소스 nomic-embed-text)이 텍스트를 고차원 벡터(보통 256-1536차원)로 바꿔요. 검색은 벡터 공간에서 가장 가까운 이웃을 찾는 문제가 되는 거예요.

잘하는 것:

  • 모호한 쿼리 뒤의 의도 파악
  • 다국어 검색 (임베딩이 언어 장벽을 넘어감)
  • 단어가 하나도 안 겹치는데도 의미적으로 관련된 콘텐츠 찾기

약한 곳:

  • 정확한 키워드 매칭 (아이러니하게도!)
  • 임베딩 모델이 못 본 희귀 기술 용어
  • 최신성 — 임베딩은 뭐가 "새로운"지 모름
  • 필터/패싯 쿼리 ("React 태그에 2025년 이후 게시글")

3세대: 하이브리드 검색 + 리랭킹 (2026년의 정답)

핵심 인사이트: 키워드 검색과 벡터 검색은 서로 반대 방향에서 실패해요. 둘을 합치면 서로의 구멍을 메워줘요.

유저 쿼리
    ↓
┌──────────────────────┐
│  병렬 검색            │
│  ┌─────────────────┐ │
│  │ BM25 (키워드)    │──→ 상위 20개 키워드 결과
│  └─────────────────┘ │
│  ┌─────────────────┐ │
│  │ 벡터 (시맨틱)    │──→ 상위 20개 시맨틱 결과
│  └─────────────────┘ │
└──────────────────────┘
    ↓
Reciprocal Rank Fusion (병합 + 중복 제거)
    ↓
상위 40개 후보 (병합)
    ↓
LLM 리랭커 (선택사항이지만 강력)
    ↓
최종 상위 10개 결과

이걸 만들어볼 거예요. 시작하죠.

Step 1: pgvector로 벡터 검색 세팅

시작부터 전용 벡터 DB가 필요한 건 아니에요. PostgreSQL + pgvector 확장만으로도 수백만 벡터를 잘 처리하고, 모든 걸 하나의 DB에 모아둘 수 있다는 장점이 있어요.

데이터베이스 세팅

-- pgvector 활성화 CREATE EXTENSION IF NOT EXISTS vector; -- 임베딩 컬럼이 있는 문서 테이블 CREATE TABLE documents ( id SERIAL PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL, metadata JSONB DEFAULT '{}', embedding vector(1536), -- OpenAI text-embedding-3-small 차원 created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- 빠른 근사 최근접 이웃 검색을 위한 HNSW 인덱스 -- 이게 대규모에서 성능의 핵심 CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64); -- BM25용 풀텍스트 검색 인덱스도 만들기 ALTER TABLE documents ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', coalesce(title, '')), 'A') || setweight(to_tsvector('english', coalesce(content, '')), 'B') ) STORED; CREATE INDEX ON documents USING gin(search_vector);

임베딩 생성

import OpenAI from 'openai'; const openai = new OpenAI(); async function generateEmbedding(text: string): Promise<number[]> { // 모델 최대 토큰에 맞게 잘라내기 const truncated = text.slice(0, 8000); const response = await openai.embeddings.create({ model: 'text-embedding-3-small', input: truncated, dimensions: 1536, }); return response.data[0].embedding; } // 배치 임베딩 (한 번에 최대 2048개) async function generateEmbeddings( texts: string[] ): Promise<number[][]> { const batchSize = 100; const allEmbeddings: number[][] = []; for (let i = 0; i < texts.length; i += batchSize) { const batch = texts.slice(i, i + batchSize); const response = await openai.embeddings.create({ model: 'text-embedding-3-small', input: batch.map(t => t.slice(0, 8000)), dimensions: 1536, }); allEmbeddings.push( ...response.data.map(d => d.embedding) ); // Rate limit 존중 if (i + batchSize < texts.length) { await new Promise(r => setTimeout(r, 100)); } } return allEmbeddings; }

기본 벡터 검색

import { Pool } from 'pg'; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); async function vectorSearch( query: string, limit: number = 10 ): Promise<SearchResult[]> { const queryEmbedding = await generateEmbedding(query); const result = await pool.query(` SELECT id, title, content, metadata, 1 - (embedding <=> $1::vector) AS similarity FROM documents WHERE embedding IS NOT NULL ORDER BY embedding <=> $1::vector LIMIT $2 `, [JSON.stringify(queryEmbedding), limit]); return result.rows; }

<=> 연산자가 코사인 거리를 계산해요. 1에서 빼면 코사인 유사도가 됨 (높을수록 비슷).

성능 튜닝

HNSW 인덱스에서 핵심 파라미터가 ef_search예요. 속도와 재현율(정확도)의 트레이드오프를 조절해요.

-- 기본값: ef_search = 40 (빠름, ~95% 재현율) SET hnsw.ef_search = 40; -- 더 높은 정확도: ef_search = 100 (~99% 재현율, 2-3배 느림) SET hnsw.ef_search = 100; -- 프로덕션에서는 유즈케이스에 따라 쿼리별 세팅

100만 문서 벤치마크 (1536차원):

ef_searchRecall@10레이턴시 (p50)레이턴시 (p99)
4095.2%5ms15ms
10098.8%12ms30ms
20099.5%25ms55ms

대부분의 앱에서 ef_search = 100이 적정이에요.

Step 2: 키워드 검색 (BM25) 추가

벡터 검색만으로는 부족해요. 유저가 "ERROR-4012"이나 "RFC 7519"를 검색하면, 키워드 검색이 객관적으로 더 나아요. BM25 풀텍스트 검색을 넣어봐요.

async function keywordSearch( query: string, limit: number = 10 ): Promise<SearchResult[]> { // 유저 쿼리를 tsquery로 변환, 특수문자 처리 const sanitized = query.replace(/[^\w\s]/g, ' ').trim(); const tsQuery = sanitized.split(/\s+/).join(' & '); const result = await pool.query(` SELECT id, title, content, metadata, ts_rank_cd(search_vector, to_tsquery('english', $1)) AS rank FROM documents WHERE search_vector @@ to_tsquery('english', $1) ORDER BY rank DESC LIMIT $2 `, [tsQuery, limit]); return result.rows; }

Step 3: Reciprocal Rank Fusion으로 하이브리드 검색

여기가 마법이에요. 키워드 결과와 벡터 결과를 합치는 거죠. 표준 방식은 **Reciprocal Rank Fusion (RRF)**인데, 서로 다른 시스템의 점수를 정규화할 필요 없이 순위 리스트를 합칠 수 있어요.

RRF 동작 원리

RRF 점수 = Σ (1 / (k + rank_i))

k는 상수(보통 60)이고, rank_i는 각 결과 리스트에서 문서의 위치예요. 양쪽 리스트 모두에서 1위인 문서는, 한쪽에서만 1위인 문서보다 더 높은 합산 점수를 받아요.

구현

interface SearchResult { id: number; title: string; content: string; metadata: Record<string, unknown>; score: number; } interface HybridSearchOptions { limit?: number; keywordWeight?: number; // 0-1, 키워드 가중치 vectorWeight?: number; // 0-1, 벡터 가중치 rrfK?: number; // RRF 상수, 기본 60 } async function hybridSearch( query: string, options: HybridSearchOptions = {} ): Promise<SearchResult[]> { const { limit = 10, keywordWeight = 0.3, vectorWeight = 0.7, rrfK = 60, } = options; // 두 검색을 병렬로 const candidateCount = limit * 4; // 더 나은 병합을 위해 넉넉하게 const [keywordResults, vectorResults] = await Promise.all([ keywordSearch(query, candidateCount), vectorSearch(query, candidateCount), ]); // 순위 맵 만들기 const rrfScores = new Map<number, { score: number; doc: SearchResult; }>(); // 키워드 결과 점수 매기기 keywordResults.forEach((doc, index) => { const rank = index + 1; const rrfScore = keywordWeight * (1 / (rrfK + rank)); rrfScores.set(doc.id, { score: rrfScore, doc, }); }); // 벡터 결과 점수 매기기 (있으면 합산, 없으면 새로 추가) vectorResults.forEach((doc, index) => { const rank = index + 1; const rrfScore = vectorWeight * (1 / (rrfK + rank)); const existing = rrfScores.get(doc.id); if (existing) { existing.score += rrfScore; // 양쪽에 다 있으면 — 부스트! } else { rrfScores.set(doc.id, { score: rrfScore, doc, }); } }); // 합산 점수로 정렬해서 반환 return Array.from(rrfScores.values()) .sort((a, b) => b.score - a.score) .slice(0, limit) .map(({ doc, score }) => ({ ...doc, score })); }

가중치 조절 가이드

keywordWeightvectorWeight 파라미터가 강력한 튜닝 노브예요:

유즈케이스키워드 가중치벡터 가중치이유
일반 Q&A0.30.7의도가 더 중요
코드 검색0.60.4정확한 심볼이 중요
에러 조회0.70.3에러 코드는 정확 매칭
대화형0.20.8자연어 쿼리
다국어0.10.9임베딩이 언어를 넘김

Step 4: 시맨틱 리랭킹 (품질 부스터)

하이브리드 검색이 80%를 채워줘요. 리랭킹이 나머지 20%를 잡아주는데, 이 20%가 "괜찮은 검색"과 "마법 같은 검색"을 가르는 차이인 경우가 많아요.

리랭킹의 역할

검색(벡터 + 키워드)은 재현율 최적화예요 — 그물을 넓게 치는 거죠. 리랭킹은 정밀도 최적화 — 각 후보를 꼼꼼히 보고 쿼리에 얼마나 관련 있는지 섬세하게 점수를 매겨요.

리랭커는 쿼리와 각 후보 문서를 쌍으로 받아서 관련도 점수를 내요. 임베딩과 달리(쿼리와 문서를 따로 인코딩) 리랭커는 둘을 같이 보기 때문에 더 세밀한 관련도를 잡아낼 수 있어요.

크로스 인코더 리랭커

import Anthropic from '@anthropic-ai/sdk'; const anthropic = new Anthropic(); interface RerankedResult extends SearchResult { rerankerScore: number; relevanceReason: string; } async function rerank( query: string, candidates: SearchResult[], topK: number = 10 ): Promise<RerankedResult[]> { const candidateList = candidates .map((c, i) => `[${i}] Title: ${c.title}\nContent: ${c.content.slice(0, 500)}`) .join('\n\n'); const response = await anthropic.messages.create({ model: 'claude-sonnet-4-20250514', max_tokens: 2000, messages: [{ role: 'user', content: `You are a search relevance judge. Given a query and candidate documents, score each document's relevance from 0.0 to 1.0. Query: "${query}" Candidates: ${candidateList} Return JSON array: [{"index": 0, "score": 0.95, "reason": "directly answers the query"}, ...] Score criteria: - 1.0: Directly and completely answers the query - 0.7-0.9: Highly relevant, addresses the core intent - 0.4-0.6: Partially relevant, related topic - 0.1-0.3: Tangentially related - 0.0: Not relevant at all Return ONLY the JSON array, no other text.`, }], }); const scores = JSON.parse( (response.content[0] as { text: string }).text ) as { index: number; score: number; reason: string }[]; return scores .sort((a, b) => b.score - a.score) .slice(0, topK) .map(s => ({ ...candidates[s.index], rerankerScore: s.score, relevanceReason: s.reason, })); }

전용 리랭커 모델 (저렴한 대안)

LLM 리랭킹은 강력하지만 비싸요. 검색량이 많으면 전용 리랭커 모델을 쓰세요:

// Cohere Rerank 사용 예시 import { CohereClient } from 'cohere-ai'; const cohere = new CohereClient({ token: process.env.COHERE_API_KEY }); async function cohereRerank( query: string, candidates: SearchResult[], topK: number = 10 ): Promise<RerankedResult[]> { const response = await cohere.v2.rerank({ model: 'rerank-v3.5', // 또는 최신 'rerank-v4.0-pro' query, documents: candidates.map(c => ({ text: `${c.title}\n${c.content.slice(0, 1000)}`, })), topN: topK, }); return response.results.map(r => ({ ...candidates[r.index], rerankerScore: r.relevanceScore, relevanceReason: '', })); }

하루 리랭킹 쿼리 1,000건 기준 비용 비교 (후보 20개씩):

리랭커레이턴시월 비용
Claude Sonnet (LLM)~800ms~$90
Cohere Rerank v4.0~180ms~$6
Cohere Rerank v3.5~200ms~$5
Jina Reranker v2~150ms~$4
셀프호스팅 (cross-encoder)~100ms서버 비용만

대부분의 앱에는 전용 리랭커가 최선이에요. LLM 리랭킹은 해당 결과가 관련 있는지 설명이 필요한 경우에만 쓰세요.

Step 5: 전체 조합

최종 검색 파이프라인을 하나의 프로덕션 함수로:

interface SearchConfig { limit: number; keywordWeight: number; vectorWeight: number; useReranker: boolean; rerankerType: 'llm' | 'cohere' | 'none'; candidateMultiplier: number; } const DEFAULT_CONFIG: SearchConfig = { limit: 10, keywordWeight: 0.3, vectorWeight: 0.7, useReranker: true, rerankerType: 'cohere', candidateMultiplier: 4, }; async function search( query: string, config: Partial<SearchConfig> = {} ): Promise<SearchResult[]> { const cfg = { ...DEFAULT_CONFIG, ...config }; const candidateCount = cfg.limit * cfg.candidateMultiplier; // 1단계: 병렬 검색 const [keywordResults, vectorResults] = await Promise.all([ keywordSearch(query, candidateCount), vectorSearch(query, candidateCount), ]); // 2단계: Reciprocal Rank Fusion const fused = reciprocalRankFusion( keywordResults, vectorResults, cfg ); // 3단계: 리랭킹 (선택) if (cfg.useReranker && fused.length > 0) { const rerankerInput = fused.slice(0, cfg.limit * 2); if (cfg.rerankerType === 'llm') { return rerank(query, rerankerInput, cfg.limit); } else if (cfg.rerankerType === 'cohere') { return cohereRerank(query, rerankerInput, cfg.limit); } } return fused.slice(0, cfg.limit); }

프로덕션 고려사항

파이프라인 만드는 건 쉬워요. 진짜 엔지니어링은 이걸 대규모에서 안정적이고, 빠르고, 비용 효율적으로 돌리는 거예요.

1. 임베딩 신선도

문서가 바뀌면 임베딩이 낡아요. 전략이 필요해요:

// 옵션 1: 쓰기 시점에 동기 생성 (간단, 쓰기 레이턴시 증가) async function updateDocument(id: number, content: string) { const embedding = await generateEmbedding(content); await pool.query(` UPDATE documents SET content = $1, embedding = $2::vector, updated_at = NOW() WHERE id = $3 `, [content, JSON.stringify(embedding), id]); } // 옵션 2: 비동기 임베딩 큐 (프로덕션 추천) import { Queue } from 'bullmq'; const embeddingQueue = new Queue('embeddings', { connection: { host: 'localhost', port: 6379 }, }); async function updateDocumentAsync(id: number, content: string) { // 콘텐츠는 즉시 업데이트 await pool.query( 'UPDATE documents SET content = $1, updated_at = NOW() WHERE id = $2', [content, id] ); // 임베딩 생성은 큐에 await embeddingQueue.add('generate', { documentId: id, content, }, { attempts: 3, backoff: { type: 'exponential', delay: 1000 }, }); }

2. 쿼리 전처리

날것의 유저 쿼리는 검색 파이프라인에 넣기 전에 좀 손봐야 할 때가 있어요:

async function preprocessQuery(rawQuery: string): Promise<{ processedQuery: string; searchConfig: Partial<SearchConfig>; }> { // 1. 정확한 코드/에러 조회인지 감지 const isExactMatch = /^[A-Z]+-\d+$|^ERROR|^0x|^HTTP \d{3}/.test(rawQuery); if (isExactMatch) { return { processedQuery: rawQuery, searchConfig: { keywordWeight: 0.9, vectorWeight: 0.1, useReranker: false }, }; } // 2. 축약 쿼리 확장 (LLM 스텝, 선택) // "k8s OOM pod restart" → "Kubernetes out of memory pod restart troubleshooting" // 3. 다국어 감지 // 임베딩은 자연스럽게 다국어 처리하지만, BM25는 언어별 설정 필요 return { processedQuery: rawQuery, searchConfig: {}, }; }

3. 캐싱 전략

임베딩 생성이 가장 비싼 작업이에요. 공격적으로 캐싱하세요:

import { Redis } from 'ioredis'; const redis = new Redis(process.env.REDIS_URL); async function getCachedEmbedding(text: string): Promise<number[] | null> { const key = `emb:${simpleHash(text)}`; const cached = await redis.get(key); if (cached) return JSON.parse(cached); return null; } async function cacheEmbedding(text: string, embedding: number[]): Promise<void> { const key = `emb:${simpleHash(text)}`; await redis.set(key, JSON.stringify(embedding), 'EX', 86400); // 24시간 TTL } // 캐싱 적용 래퍼 async function getEmbedding(text: string): Promise<number[]> { const cached = await getCachedEmbedding(text); if (cached) return cached; const embedding = await generateEmbedding(text); await cacheEmbedding(text, embedding); return embedding; }

4. 모니터링과 품질 측정

측정 안 하면 개선 못 해요. 이 지표들을 추적하세요:

interface SearchMetrics { // 성능 totalLatencyMs: number; embeddingLatencyMs: number; retrievalLatencyMs: number; rerankLatencyMs: number; // 품질 (유저 피드백이나 암시적 신호 필요) clickThroughRate: number; // 클릭이 있는 검색 비율 meanReciprocalRank: number; // 첫 클릭 결과의 1/순위 평균 noResultsRate: number; // 결과 0건인 검색 비율 // 비용 embeddingTokensUsed: number; rerankerCallsMade: number; }

5. PostgreSQL 너머로 확장

pgvector는 ~500만 벡터까지 잘 버텨요. 그 이상이면:

규모추천이유
< 10만 벡터pgvector심플하게, 같은 DB
10만 - 500만pgvector + HNSW 튜닝아직 괜찮음, m과 ef 튜닝
500만 - 5000만전용 벡터 DBPinecone, Weaviate, Qdrant
5000만+분산 벡터 DBMilvus, Vespa, 커스텀

pgvector에서 전용 벡터 DB로 마이그레이션하는 길이 깔끔해요 — 임베딩 생성과 검색 API는 그대로, 저장/쿼리 레이어만 바꾸면 돼요.

임베딩 모델 선택

검색 시스템에서 임베딩 모델 선택이 가장 중요한 결정이에요. 현재 판도:

모델차원최대 토큰품질 (MTEB)비용/100만 토큰추천
OpenAI text-embedding-3-small1536819162.3$0.02가성비 기본값
OpenAI text-embedding-3-large3072819164.6$0.13API 중 최고 품질
Cohere embed-v4.0256–1536128,00066.2$0.10다국어, 멀티모달
Voyage AI voyage-3256–204832,00067.1$0.06긴 문서
nomic-embed-text (오픈소스)64–768819262.4무료 (셀프호스팅)프라이버시, API 비용 없음
BGE-M3 (오픈소스)1024819263.0무료 (셀프호스팅)다국어, 셀프호스팅

추천:

  • 시작할 때: OpenAI text-embedding-3-small — 싸고, 충분히 좋고, API 간편
  • 다국어: Cohere embed-v4.0 또는 BGE-M3
  • 프라이버시 중요: nomic-embed-text (로컬 실행)
  • 최고 품질: Voyage AI voyage-3

주의: 임베딩 모델을 한 번 정하면, 나중에 바꾸려면 전체 코퍼스를 다시 임베딩해야 해요. 신중하게 고르고, 미래 규모까지 생각하세요.

흔한 함정 (그리고 피하는 법)

함정 1: 너무 잘게 쪼개기

문서를 너무 작은 청크로 쪼개면 맥락이 사라져요. "이것은 응답을 캐싱해서 처리한다"라는 임베딩은, "이것"과 "응답"이 뭘 가리키는지 모르면 의미가 없어요.

// ❌ 비추: 200토큰 고정 청크는 맥락 손실 const chunks = splitByTokenCount(document, 200); // ✅ 추천: 오버랩 있는 시맨틱 청킹 function semanticChunk(text: string): string[] { const paragraphs = text.split(/\n\n+/); const chunks: string[] = []; let current = ''; for (const para of paragraphs) { if (current.length + para.length > 1500) { if (current) chunks.push(current); current = para; } else { current += '\n\n' + para; } } if (current) chunks.push(current); // 오버랩: 이전 청크의 마지막 문장 붙이기 return chunks.map((chunk, i) => { if (i === 0) return chunk; const prevLastSentence = chunks[i - 1].split(/\. /).pop(); return `${prevLastSentence}. ${chunk}`; }); }

함정 2: 메타데이터 필터링 무시

벡터 검색이 유일한 필터가 돼선 안 돼요. 벡터 검색 전에 메타데이터로 미리 걸러세요:

-- ❌ 비추: 전체에서 검색 후 필터링 SELECT * FROM documents ORDER BY embedding <=> $1::vector LIMIT 10; -- 그 다음 앱 코드에서 필터 -- ✅ 추천: 먼저 필터, 서브셋 안에서 검색 SELECT * FROM documents WHERE metadata->>'category' = 'engineering' AND created_at > NOW() - INTERVAL '90 days' ORDER BY embedding <=> $1::vector LIMIT 10;

함정 3: 실제 쿼리로 테스트 안 하기

실제 유저 쿼리(검색 로그, 서포트 티켓, 피드백)로 테스트 세트를 만드세요. NDCG나 MRR 같은 자동 지표도 유용하지만, 상위 50개 쿼리의 결과를 직접 눈으로 보는 게 최고예요.

// 골든 테스트 세트 만들기 const testCases = [ { query: "로그인 안 될 때 무한루프 해결", expectedTopResult: "인증 토큰 만료 문제 해결", expectedInTop5: ["인증 트러블슈팅 가이드", "세션 관리"], }, // ... 실제 검색 로그에서 가져온 50개 더 ]; async function evaluateSearch() { let hits = 0; for (const tc of testCases) { const results = await search(tc.query, { limit: 5 }); if (results.some(r => r.title === tc.expectedTopResult)) { hits++; } } console.log(`Recall@5: ${(hits / testCases.length * 100).toFixed(1)}%`); }

함정 4: 콜드 스타트 무시

론칭할 때는 검색 로그가 없어요. 유저가 뭘 검색할지 모르죠. 키워드 가중치를 넉넉하게 잡고 시작하고(0.5/0.5 하이브리드), 쿼리 데이터가 쌓이면 점진적으로 벡터 쪽으로 옮기세요.

결론: 검색 스택 의사결정 가이드

AI 검색은 기술 하나를 고르는 게 아니에요. 맞게 쌓아올리는 게 핵심이에요:

  1. 하이브리드 검색(BM25 + 벡터)부터. 이것만으로도 단독 접근보다 15-25% 나아요.

  2. 정밀도가 필요하면 리랭킹 추가. Cohere Rerank 한 번에 ~200ms, 비용은 푼돈인데 상위 3개 결과 품질이 확 올라가요.

  3. 특별한 이유 없으면 pgvector. 기존 PostgreSQL에 벡터를 같이 넣으면 운영, 트랜잭션, 백업, JOIN 전부 심플해져요.

  4. 집요하게 측정. CTR, 결과 없음 비율을 추적하고, 실제 쿼리로 골든 테스트 세트를 만드세요. 측정 없이 튜닝하는 건 감으로 운전하는 거예요.

  5. 첫날부터 임베딩 오버엔지니어링 금지. text-embedding-3-small로 시작, 배포, 실제 쿼리 모으고, 그 다음에 더 강력한(더 비싼) 모델이 필요한지 결정하세요.

"키워드 검색"과 "AI 검색"의 격차는 이제 박사 논문이 아니에요. 이 가이드의 패턴이면 개발자 한 명이 주말에 만들 수 있는 수준인데, 5년 전이었으면 검색 전담팀이 한 분기 걸렸을 거예요. 도구는 성숙했고, 패턴은 검증됐어요. 남은 건 만드는 것뿐이에요.

AIsearchvector-databaseembeddingspgvectorPostgreSQLTypeScriptRAGsemantic-searchproduction

관련 도구 둘러보기

Pockit의 무료 개발자 도구를 사용해 보세요