2026-03-19

검색 스코어링 튜닝 삽질기 — 35%가 최대였던 검색을 100%까지 올린 과정

블로그 검색에 벡터 유사도 + 키워드 검색을 결합한 하이브리드 방식을 쓰고 있는데, 어느 날 검색 결과를 보니 이상했다. "네이티브 이미지"를 검색했는데 1등이 35%. 직관적으로 뭔가 이상하다. 제목에 "이미지"가 들어간 글이 35%라니.

알고 보니 스코어링 계산식 자체에 구조적 결함이 있었다. 이 글은 그걸 발견하고 고치는 과정을 정리한 삽질 기록이다.

원인: ts_rank와 벡터의 스케일 불일치

기존 하이브리드 스코어링은 이렇게 생겼다.

finalScore = 0.4 * keywordScore + 0.6 * vectorScaled

키워드 40%, 벡터 60%. 얼핏 괜찮아 보인다. 문제는 각 점수의 범위가 달랐다는 거다.

점수계산 방식실제 범위keywordScorets_rank (raw 값 그대로)0.06 ~ 0.27vectorScaledscaleSimilarity로 0~1 보정0 ~ 1

벡터는 0~1로 보정했는데 키워드는 raw 값 그대로 썼다. ts_rank는 이론적으로 0~무한대인데, 실제로는 0.06~0.27 범위에 몰려 있다. 0.4를 곱해봐야 최대 0.108. 사실상 키워드 기여가 거의 0이었다.

0.4 * keywordScore 부분이 항상 0.04 수준이라, 최종 점수가 0.6 * vectorScaled = 최대 60%를 넘을 수 없는 구조였다. 35%가 나온 게 당연한 거였다.

1차 시도: BM25로 엔진 교체

ts_rank의 좁은 점수 범위를 근본적으로 해결하려면 키워드 검색 엔진을 바꿔야 했다. Neon에서 pg_search(ParadeDB)의 BM25를 지원하는 걸 확인하고 전환했다.

한국어 테스트부터 했다.

CREATE INDEX posts_bm25_idx ON posts
  USING bm25 (id, title, description, tags, content)
  WITH (key_field='id');
 
-- 테스트
SELECT title, paradedb.score(id) AS score
FROM posts
WHERE id @@@ paradedb.parse('title:이미지 OR content:이미지')
ORDER BY paradedb.score(id) DESC;

검색어1등 결과BM25 점수이미지OG 이미지 자동 생성하기0.85네이티브GraalVM Native Image 빌드1.35Spring BootGraalVM Native Image 빌드2.26

ts_rank(0.06~0.27)과 비교하면 BM25(0.7~15.0)는 변별력이 확실히 높다. 한국어 토크나이징도 잘 됐다.

2차: 정규화 + 가중치 조합 테스트

BM25와 벡터 점수를 합산하려면 둘 다 0~1로 정규화해야 한다. 고정 min/max 방식을 택했다.

normalizeBm25(raw) = clamp((raw - 1.0) / (15.0 - 1.0), 0, 1)
normalizeVector(raw) = clamp((raw - 0.15) / (0.65 - 0.15), 0, 1)

가중치도 세 가지를 테스트했다.

조합Spring Boot 성능Next.jspgvector0.6v + 0.4b73%89%13%0.7v + 0.3b69%87%10%0.8v + 0.2b66%85%7%

0.7v + 0.3b가 가장 균형 잡혀 보였다. 0.8은 키워드 매칭을 너무 무시하고, 0.6은 BM25에 쏠렸다.

3차: 정규화 범위가 너무 넓다

0.7v + 0.3b로 정했는데, 실제 검색하면 여전히 낮았다. "pgvector"를 검색했는데 제목에 pgvector가 들어있는 글이 54%.

실데이터 분포를 다시 측정했다.

// BM25 1등 점수 분포 (16개 검색어)
MIN=1.99, MEDIAN=9.82, P75=14.27, MAX=16.71
 
// 벡터 1등 점수 분포
MIN=0.280, MEDIAN=0.421, P75=0.489, MAX=0.561

BM25 max를 15.0으로 잡으니 median(9.8)이 60% 수준밖에 안 됐다. max를 10.0으로 낮추니 단일 키워드 검색에서 점수가 확 올라갔다. 벡터도 max=0.65 → 0.55로 조정.

검색어max=15/0.65 (변경 전)max=10/0.55 (변경 후)pgvector54%73%Spring Boot 성능90%100%Next.js79%100%GraalVM76%92%

4차: 제목 부스트

점수가 좋아졌는데 한 가지 더 거슬렸다. "flix"를 검색하면 Flix 프로젝트 글이 8개나 있는데, 1등(아키텍처 소개)만 85%이고 나머지는 40~44%.

원인: 1등은 본문에 "Flix"가 많이 나와서 BM25=10.8(100%)인데, 나머지 개발 기록들은 본문에 "Flix"가 적어서 BM25=3.5(28%) 수준. 제목에 "Flix"가 다 들어있는데 본문 빈도 때문에 점수가 낮았다.

제목 매칭 부스트(+15%p)를 추가했다. 검색어가 제목에 포함되면 최종 점수에 0.15를 가산한다.

export function hybridScore(bm25Raw, vectorRaw, titleMatch = false) {
  const base = 0.7 * normalizeVector(vectorRaw)
             + 0.3 * normalizeBm25(bm25Raw);
  return Math.min(1, titleMatch ? base + TITLE_BOOST : base);
}

적용 후:

검색어1등변경 전변경 후flixFlix 아키텍처 소개70%85%flixFlix 개발 기록 - 240%55%pgvectorpgvector 관련 글 추천73%88%

5차: SQL vs JS 정렬 불일치

마지막 함정. "pgvector"를 검색했는데 54%짜리 글이 36%짜리 위에 나와야 하는데, 반대로 나왔다.

원인: SQL ORDER BY는 raw 값으로 정렬하고, JS에서 정규화한 후 다른 순서가 되는 거였다. BM25 raw가 0~15 범위이고 벡터가 0~0.65 범위라, raw 합산에서는 BM25가 지배적이었다.

-- 수정 전: raw 값으로 정렬 (BM25가 지배적)
ORDER BY (0.7 * vector_score + 0.3 * bm25_score) DESC
 
-- 수정 후: 정규화된 값으로 정렬 (JS와 동일한 순서)
ORDER BY (
  0.7 * GREATEST(0, LEAST(1, (vector_score - 0.15) / 0.4)) +
  0.3 * GREATEST(0, LEAST(1, (bm25_score - 1.0) / 9.0))
) DESC

SQL과 JS가 동일한 정규화 공식을 사용하도록 맞추니 순서 불일치가 해결됐다.

최종 결과: scoring.ts 중앙 관리

이 모든 상수와 함수를 scoring.ts 하나에 모았다. 블로그 검색, 노트 검색, 챗봇 RAG, 관련 글 추천 — 4곳에서 같은 모듈을 쓴다.

// scoring.ts
export const BM25_MIN = 1.0, BM25_MAX = 10.0;
export const VECTOR_MIN = 0.15, VECTOR_MAX = 0.55;
export const VECTOR_WEIGHT = 0.7, BM25_WEIGHT = 0.3;
export const TITLE_BOOST = 0.15;
export const HYBRID_MIN_SCORE = 0.1;
export const VECTOR_DISPLAY_MIN = 0.4;
export const RAG_MIN_SIMILARITY = 0.3;

정규화 범위를 바꾸고 싶으면 이 파일 하나만 수정하면 된다. 가중치도, 임계값도 여기서 관리한다.

교훈

스코어링 튜닝은 코드를 한 줄 바꾸는 게 아니라, 실데이터의 점수 분포를 측정하고 그에 맞는 범위를 찾는 작업이다. 코드는 단순한데, 어떤 숫자를 넣느냐가 전부다.

그리고 SQL과 JS에서 같은 공식을 쓰는 게 생각보다 중요하다. 정렬은 SQL에서, 표시는 JS에서 하니까 공식이 다르면 순서가 뒤집힌다. 당연한 건데 놓치기 쉽다.

관련 글

로딩 중...