내 앱에 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_search | Recall@10 | 레이턴시 (p50) | 레이턴시 (p99) |
|---|---|---|---|
| 40 | 95.2% | 5ms | 15ms |
| 100 | 98.8% | 12ms | 30ms |
| 200 | 99.5% | 25ms | 55ms |
대부분의 앱에서 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 })); }
가중치 조절 가이드
keywordWeight와 vectorWeight 파라미터가 강력한 튜닝 노브예요:
| 유즈케이스 | 키워드 가중치 | 벡터 가중치 | 이유 |
|---|---|---|---|
| 일반 Q&A | 0.3 | 0.7 | 의도가 더 중요 |
| 코드 검색 | 0.6 | 0.4 | 정확한 심볼이 중요 |
| 에러 조회 | 0.7 | 0.3 | 에러 코드는 정확 매칭 |
| 대화형 | 0.2 | 0.8 | 자연어 쿼리 |
| 다국어 | 0.1 | 0.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만 | 전용 벡터 DB | Pinecone, Weaviate, Qdrant |
| 5000만+ | 분산 벡터 DB | Milvus, Vespa, 커스텀 |
pgvector에서 전용 벡터 DB로 마이그레이션하는 길이 깔끔해요 — 임베딩 생성과 검색 API는 그대로, 저장/쿼리 레이어만 바꾸면 돼요.
임베딩 모델 선택
검색 시스템에서 임베딩 모델 선택이 가장 중요한 결정이에요. 현재 판도:
| 모델 | 차원 | 최대 토큰 | 품질 (MTEB) | 비용/100만 토큰 | 추천 |
|---|---|---|---|---|---|
| OpenAI text-embedding-3-small | 1536 | 8191 | 62.3 | $0.02 | 가성비 기본값 |
| OpenAI text-embedding-3-large | 3072 | 8191 | 64.6 | $0.13 | API 중 최고 품질 |
| Cohere embed-v4.0 | 256–1536 | 128,000 | 66.2 | $0.10 | 다국어, 멀티모달 |
| Voyage AI voyage-3 | 256–2048 | 32,000 | 67.1 | $0.06 | 긴 문서 |
| nomic-embed-text (오픈소스) | 64–768 | 8192 | 62.4 | 무료 (셀프호스팅) | 프라이버시, API 비용 없음 |
| BGE-M3 (오픈소스) | 1024 | 8192 | 63.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 검색은 기술 하나를 고르는 게 아니에요. 맞게 쌓아올리는 게 핵심이에요:
-
하이브리드 검색(BM25 + 벡터)부터. 이것만으로도 단독 접근보다 15-25% 나아요.
-
정밀도가 필요하면 리랭킹 추가. Cohere Rerank 한 번에 ~200ms, 비용은 푼돈인데 상위 3개 결과 품질이 확 올라가요.
-
특별한 이유 없으면 pgvector. 기존 PostgreSQL에 벡터를 같이 넣으면 운영, 트랜잭션, 백업, JOIN 전부 심플해져요.
-
집요하게 측정. CTR, 결과 없음 비율을 추적하고, 실제 쿼리로 골든 테스트 세트를 만드세요. 측정 없이 튜닝하는 건 감으로 운전하는 거예요.
-
첫날부터 임베딩 오버엔지니어링 금지.
text-embedding-3-small로 시작, 배포, 실제 쿼리 모으고, 그 다음에 더 강력한(더 비싼) 모델이 필요한지 결정하세요.
"키워드 검색"과 "AI 검색"의 격차는 이제 박사 논문이 아니에요. 이 가이드의 패턴이면 개발자 한 명이 주말에 만들 수 있는 수준인데, 5년 전이었으면 검색 전담팀이 한 분기 걸렸을 거예요. 도구는 성숙했고, 패턴은 검증됐어요. 남은 건 만드는 것뿐이에요.
관련 도구 둘러보기
Pockit의 무료 개발자 도구를 사용해 보세요