벡터 검색만으로는 부족합니다
Sonamu로 상품 검색 API를 만들었습니다:복사
@api({ httpMethod: 'POST' })
async searchProducts(query: string) {
const embedding = await Embedding.embedOne(query, 'voyage', 'query');
const results = await this.getPuri().raw(`
SELECT name, description,
1 - (embedding <=> ?) AS similarity
FROM products
ORDER BY embedding <=> ?
LIMIT 10
`, [
JSON.stringify(embedding.embedding),
JSON.stringify(embedding.embedding),
]);
return results.rows;
}
- ✅ “MacBook” → 찾음 (의미 유사)
- ✅ “맥북 프로” → 찾음 (한글도 OK)
- ❌ “MBP14” → 못 찾음 (정확한 모델명)
- ❌ “SKU-12345” → 못 찾음 (상품 코드)
- 정확한 제품명, 모델명에 약함
- 상품 코드, SKU 같은 고유 식별자 못 찾음
- 기술 용어, 약어에 취약
하이브리드 검색이란?
벡터 검색(의미) + 전문 검색/FTS(키워드)를 결합합니다. 장점:- 벡터: 의미 이해, 동의어, 오타
- FTS: 정확한 키워드, 부분 매칭
- 결합: 최고의 정확도
Sonamu에서 구현하기
1. PostgreSQL FTS 설정
먼저 전문 검색(FTS)을 준비합니다.tsvector 컬럼 추가
복사
// migrations/20240101_add_fts.ts
export async function up(knex: Knex): Promise<void> {
await knex.schema.table('products', (table) => {
table.specificType('search_vector', 'tsvector');
});
// 초기 데이터 생성
await knex.raw(`
UPDATE products
SET search_vector =
setweight(to_tsvector('simple', coalesce(name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(description, '')), 'B')
`);
// GIN 인덱스
await knex.raw(`
CREATE INDEX idx_products_search
ON products USING GIN (search_vector)
`);
}
'simple': 한국어 + 영어 처리setweight: 제목(A)에 더 높은 가중치- GIN 인덱스: 빠른 검색
자동 업데이트 트리거
복사
export async function up(knex: Knex): Promise<void> {
// 트리거 함수
await knex.raw(`
CREATE FUNCTION products_search_trigger() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.description, '')), 'B');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
// 트리거 생성
await knex.raw(`
CREATE TRIGGER tsvector_update
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION products_search_trigger();
`);
}
2. Sonamu Model에서 하이브리드 검색
복사
import { BaseModel, api } from "sonamu";
import { Embedding } from "sonamu/vector";
class ProductModelClass extends BaseModel {
@api({ httpMethod: 'POST' })
async hybridSearch(
query: string,
vectorWeight: number = 0.7,
ftsWeight: number = 0.3,
limit: number = 10
) {
// 1. 쿼리 임베딩
const embedding = await Embedding.embedOne(query, 'voyage', 'query');
// 2. 하이브리드 검색 SQL
const results = await this.getPuri().raw(`
WITH vector_results AS (
SELECT
id,
1 - (embedding <=> ?) AS vector_score
FROM products
WHERE embedding IS NOT NULL
),
fts_results AS (
SELECT
id,
ts_rank(search_vector, plainto_tsquery('simple', ?)) AS fts_score
FROM products
WHERE search_vector @@ plainto_tsquery('simple', ?)
)
SELECT
p.id,
p.name,
p.description,
p.price,
COALESCE(v.vector_score, 0) AS vector_score,
COALESCE(f.fts_score, 0) AS fts_score,
(COALESCE(v.vector_score, 0) * ? + COALESCE(f.fts_score, 0) * ?) AS hybrid_score
FROM products p
LEFT JOIN vector_results v ON p.id = v.id
LEFT JOIN fts_results f ON p.id = f.id
WHERE v.id IS NOT NULL OR f.id IS NOT NULL
ORDER BY hybrid_score DESC
LIMIT ?
`, [
JSON.stringify(embedding.embedding), // vector
query, // fts (1)
query, // fts (2)
vectorWeight, // 0.7
ftsWeight, // 0.3
limit,
]);
return results.rows.map(row => ({
id: row.id,
name: row.name,
price: row.price,
vectorScore: parseFloat(row.vector_score.toFixed(3)),
ftsScore: parseFloat(row.fts_score.toFixed(3)),
hybridScore: parseFloat(row.hybrid_score.toFixed(3)),
}));
}
}
vector_results: 벡터 유사도 계산fts_results: FTS 점수 계산LEFT JOIN: 둘 중 하나라도 매칭되면 포함- 가중 평균:
(벡터 * 0.7) + (FTS * 0.3)
가중치 전략
언제 어떤 가중치를?
균형형 (기본)복사
vectorWeight: 0.7,
ftsWeight: 0.3
- 용도: 일반적인 검색
- 예: 블로그, 문서, 지식 베이스
복사
vectorWeight: 0.9,
ftsWeight: 0.1
- 용도: 의미 이해가 중요한 경우
- 예: Q&A, 고객 지원, 추천
복사
vectorWeight: 0.3,
ftsWeight: 0.7
- 용도: 정확한 매칭이 중요한 경우
- 예: 상품 코드, 모델명, 기술 용어
Sonamu에서 동적 조정
복사
@api({ httpMethod: 'POST' })
async smartSearch(query: string, limit: number = 10) {
// 쿼리 길이에 따라 자동 조정
let vectorWeight = 0.7;
let ftsWeight = 0.3;
if (query.length < 10) {
// 짧은 쿼리: 키워드 우선
// 예: "MBP", "SKU-123"
vectorWeight = 0.3;
ftsWeight = 0.7;
} else if (query.length > 50) {
// 긴 쿼리: 의미 우선
// 예: "노트북 추천해주세요..."
vectorWeight = 0.8;
ftsWeight = 0.2;
}
return await this.hybridSearch(query, vectorWeight, ftsWeight, limit);
}
실전 시나리오
시나리오: 이커머스 상품 검색
Sonamu로 쇼핑몰을 만들고 있습니다. 1단계: 테이블 준비복사
// migrations/20240101_products.ts
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('products', (table) => {
table.increments('id').primary();
table.string('name').notNullable();
table.text('description');
table.string('sku').unique();
table.decimal('price', 10, 2);
// 벡터 + FTS
table.specificType('embedding', 'vector(1024)');
table.specificType('search_vector', 'tsvector');
table.timestamps(true, true);
});
// 인덱스
await knex.raw(`
CREATE INDEX idx_products_embedding
ON products USING hnsw (embedding vector_cosine_ops)
`);
await knex.raw(`
CREATE INDEX idx_products_search
ON products USING GIN (search_vector)
`);
// FTS 트리거
await knex.raw(`
CREATE FUNCTION products_search_trigger() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.description, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(NEW.sku, '')), 'A');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tsvector_update
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION products_search_trigger();
`);
}
복사
@api({ httpMethod: 'POST' })
async addProduct(
name: string,
description: string,
sku: string,
price: number
) {
// 임베딩 생성
const embedding = await Embedding.embedOne(
`${name}\n\n${description}`,
'voyage',
'document'
);
// 저장 (search_vector는 트리거가 자동 생성)
const product = await this.saveOne({
name,
description,
sku,
price,
embedding: embedding.embedding,
});
return product;
}
복사
@api({ httpMethod: 'POST' })
async searchProducts(
query: string,
filters: {
minPrice?: number;
maxPrice?: number;
category?: string;
} = {},
limit: number = 20
) {
const embedding = await Embedding.embedOne(query, 'voyage', 'query');
// 필터 조건
const conditions: string[] = [];
const params: any[] = [
JSON.stringify(embedding.embedding),
query,
query,
0.6, // vectorWeight
0.4, // ftsWeight
];
if (filters.minPrice) {
conditions.push(`p.price >= ?`);
params.push(filters.minPrice);
}
if (filters.maxPrice) {
conditions.push(`p.price <= ?`);
params.push(filters.maxPrice);
}
if (filters.category) {
conditions.push(`p.category = ?`);
params.push(filters.category);
}
const whereClause = conditions.length > 0
? `AND ${conditions.join(' AND ')}`
: '';
params.push(limit);
const results = await this.getPuri().raw(`
WITH vector_results AS (
SELECT
id,
1 - (embedding <=> ?) AS vector_score
FROM products
WHERE embedding IS NOT NULL
),
fts_results AS (
SELECT
id,
ts_rank(search_vector, plainto_tsquery('simple', ?)) AS fts_score
FROM products
WHERE search_vector @@ plainto_tsquery('simple', ?)
)
SELECT
p.id,
p.name,
p.description,
p.sku,
p.price,
COALESCE(v.vector_score, 0) AS vector_score,
COALESCE(f.fts_score, 0) AS fts_score,
(COALESCE(v.vector_score, 0) * ? + COALESCE(f.fts_score, 0) * ?) AS hybrid_score
FROM products p
LEFT JOIN vector_results v ON p.id = v.id
LEFT JOIN fts_results f ON p.id = f.id
WHERE (v.id IS NOT NULL OR f.id IS NOT NULL)
${whereClause}
ORDER BY hybrid_score DESC
LIMIT ?
`, params);
return results.rows;
}
복사
// 일반 검색
await ProductModel.searchProducts("MacBook Pro");
// 필터 적용
await ProductModel.searchProducts("노트북", {
minPrice: 1000000,
maxPrice: 2000000,
category: "laptop",
});
벤치마크
검색 정확도 비교
실제 1000개 상품 DB에서 테스트:| 방식 | 정확도 (MAP@10) | 장점 | 단점 |
|---|---|---|---|
| 키워드만 (LIKE) | 0.45 | 빠름 | 의미 못 찾음 |
| FTS만 | 0.68 | 부분 매칭 | 동의어 약함 |
| 벡터만 | 0.72 | 의미 이해 | 정확한 매칭 약함 |
| 하이브리드 | 0.85 | 양쪽 장점 | 복잡함 |
주의사항
Sonamu에서 하이브리드 검색 시 주의사항:
-
두 인덱스 필수: 벡터 + FTS
복사
CREATE INDEX ... USING hnsw (embedding vector_cosine_ops); CREATE INDEX ... USING GIN (search_vector); -
tsvector 업데이트: 트리거로 자동화
복사
CREATE TRIGGER tsvector_update ... -
가중치 합 = 1: 정규화
복사
const total = vectorWeight + ftsWeight; vectorWeight = vectorWeight / total; ftsWeight = ftsWeight / total; -
NULL 처리: COALESCE 사용
복사
COALESCE(v.vector_score, 0) -
LEFT JOIN: 한쪽만 매칭도 OK
복사
WHERE v.id IS NOT NULL OR f.id IS NOT NULL -
한국어는 ‘simple’: FTS 언어 설정
복사
to_tsvector('simple', text) -
성능 모니터링: EXPLAIN ANALYZE
복사
EXPLAIN ANALYZE [하이브리드 쿼리]
언제 하이브리드를 쓰나요?
하이브리드 추천
✅ 이커머스 상품 검색- 의미 + 모델명, SKU
- 개념 + 함수명, 코드
- 문제 설명 + 정확한 용어
벡터만으로 충분
❌ 추천 시스템- 키워드 불필요
- 텍스트 키워드 없음
- 의미만 중요
