메인 콘텐츠로 건너뛰기

벡터 검색만으로는 부족합니다

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 Pro 14”로 검색하면?
  • ✅ “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();
  `);
}
이제 상품 추가/수정 시 자동으로 search_vector가 업데이트됩니다.

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)),
    }));
  }
}
SQL 설명:
  1. vector_results: 벡터 유사도 계산
  2. fts_results: FTS 점수 계산
  3. LEFT JOIN: 둘 중 하나라도 매칭되면 포함
  4. 가중 평균: (벡터 * 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();
  `);
}
2단계: 상품 추가 API
@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;
}
3단계: 하이브리드 검색 API
@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양쪽 장점복잡함
결론: 하이브리드가 15-20% 더 정확합니다.

주의사항

Sonamu에서 하이브리드 검색 시 주의사항:
  1. 두 인덱스 필수: 벡터 + FTS
    CREATE INDEX ... USING hnsw (embedding vector_cosine_ops);
    CREATE INDEX ... USING GIN (search_vector);
    
  2. tsvector 업데이트: 트리거로 자동화
    CREATE TRIGGER tsvector_update ...
    
  3. 가중치 합 = 1: 정규화
    const total = vectorWeight + ftsWeight;
    vectorWeight = vectorWeight / total;
    ftsWeight = ftsWeight / total;
    
  4. NULL 처리: COALESCE 사용
    COALESCE(v.vector_score, 0)
    
  5. LEFT JOIN: 한쪽만 매칭도 OK
    WHERE v.id IS NOT NULL OR f.id IS NOT NULL
    
  6. 한국어는 ‘simple’: FTS 언어 설정
    to_tsvector('simple', text)
    
  7. 성능 모니터링: EXPLAIN ANALYZE
    EXPLAIN ANALYZE [하이브리드 쿼리]
    

언제 하이브리드를 쓰나요?

하이브리드 추천

이커머스 상품 검색
  • 의미 + 모델명, SKU
기술 문서 검색
  • 개념 + 함수명, 코드
고객 지원
  • 문제 설명 + 정확한 용어

벡터만으로 충분

추천 시스템
  • 키워드 불필요
이미지 검색
  • 텍스트 키워드 없음
유사 문서 찾기
  • 의미만 중요

다음 단계