"이거 어제도 올라온 피드백인데요?" — 중복 피드백을 잡아내는 하이브리드 검색

같은 피드백이 다른 표현으로 반복돼 투표가 분산되는 문제를, 단일 임베딩 검색에서 시맨틱+키워드 하이브리드(RRF)로 다시 설계한 기록. 모델 교체 없이 아키텍처를 개선해 정밀도 61%→89% 향상시켰답니다.

스토리최종 수정 2026년 7월 1일·7분 읽기·

들어가며


레닛(Lenit) 은 고객의 기능 요청을 모아 투표로 우선순위를 매기는 피드백 보드 서비스에요. 사용자가 많아지고, 피드백이 많아질수록 같은 요청이 조금씩 다른 표현으로 반복되고있어요.

  • "다크모드 만들어주세요"

  • "밤에 눈부셔서 야간 테마 필요해요"

  • "어두운 화면 옵션 없나요?"

세 개는 사실상 같은 요청이지만, 시스템 입장에선 서로 다른 세 개의 아이디어에요. 중복이 쌓이면 투표가 분산되고(20표 + 15표 + 8표 → 진짜 우선순위가 가려짐), 관리자는 매번 수동으로 병합 해야 한답니다.

흔히 이런 문제를 만나면 "더 큰 모델로 바꾸면 되지 않을까?"부터 떠올리게 돼요. 하지만 레닛에서는 모델은 그대로 두고, 검색하는 아키텍처를 바꾸기로 했거든요. 이 글은 단일 임베딩 검색의 한계를 진단하고, 하이브리드 검색으로 재설계한 과정을 담았어요.


1. 문제 상황 — 단일 임베딩 검색의 한계

1.1 초기 구조

처음 만든 중복 감지는 단순했어요. 아이디어가 생성되면 제목+설명을 OpenAI text-embedding-3-small(1536차원)로 임베딩하고, PostgreSQL pgvector에 저장한 뒤, 새 글을 쓸 때 코사인 유사도로 비슷한 글을 보여주고 있었죠.

새 아이디어 → 임베딩(1536d) → pgvector
                              │
새 글 작성 중 ──임베딩──►  ORDER BY embedding <=> query (코사인 거리)

                          WHERE 1 - (embedding <=> query) >= 0.55

검색 쿼리는 이게 전부였답니다. (정말 간단하죠?)

SELECT id, 1 - (embedding <=> :q) AS similarity
  FROM ideas
 WHERE organization_id = :org
   AND embedding IS NOT NULL
 ORDER BY embedding <=> :q
 LIMIT 5;

데모 수준에선 잘 동작하는 것처럼 보였지만, 운영 보드에 글이 수천 개 쌓이자 문제가 드러나기 시작했어요. 실제로 중복을 의미하는 피드백 내용이였지만, 병합되지 않고, 비슷한 피드백에서 보여주지 않는다는 사용자 피드백이 있었거든요.

1.2 무엇이 문제였지 ?

운영 데이터로 측정해보니 단일 코사인 유사도는 정밀도(precision)와 재현율(recall)을 동시에 잡지 못한다는걸 발견했어요. 임계값 하나로 두 마리 토끼를 쫓는 구조였기 때문이였죠;

임계값

증상

0.55 (낮춤)

관련 없는 글까지 "중복"으로 추천 → 관리자가 신뢰를 잃고 기능을 안 봄

0.85 (높임)

"다크모드" ↔ "야간 테마"처럼 표현만 다른 진짜 중복을 놓침

구체적으로 세 가지 실패 패턴이 반복됐어요.

① 고유명사·기능명을 잡지 못한다.

"OAuth 토큰 만료 버그"와 "OAuth 로그인 안 됨"은 임베딩상 꽤 가깝게 나오죠. 반대로 "알림톡"과 "카카오 알림" 처럼 같은 기능을 가리키는 정확한 키워드는, 문장 전체 임베딩에선 다른 단어들에 희석돼 유사도가 낮게 나오고있었답니다. 임베딩은 "의미의 평균"을 보기 때문에 정확한 명사 일치라는 강한 신호를 약하게 다룬다는점을 알게됐죠.

② 카테고리를 넘나드는 오탐

"결제 화면 색상 바꿔주세요"(UI)와 "결제 실패해요"(버그)는 '결제'라는 단어 때문에 유사도가 높게 잡혔어요.

명백히 다른 토픽임에도 유사도가 같다는 이유만으로, 비슷한 피드백에 등재되었던거죠.

③ 입력 텍스트 설계 부재

제목과 설명을 그냥 이어 붙여 임베딩했어요. 설명이 장문이면,, 정작 핵심인 제목 신호가 묻히던 문제가 있었어요.

"검색 모델이 약해서"가 아니라, 단일 신호(임베딩),단일 임계값·평면적 입력이라는 구조 때문이란걸 파악할 수 있었어요.

이 진단이 이후 설계의 방향을 정할 수 있었어요. 모델을 더 큰 걸로 바꾸는 게 아니라, 검색 파이프라인 자체를 다층화하기로 결정했답니다.

2. 해결 방안 설계 키워드


"원본 데이터 준비가 모델보다 중요하다"

"메타데이터로 검색 공간을 좁혀라"

"시맨틱과 키워드를 합쳐라"

원칙 1 — 입력을 먼저 설계한다 (Garbage In, Garbage Out)

임베딩 품질은 모델이 아니라 무엇을 넣느냐가 8할이에요 . 평면적 title + description 대신, 가중 입력으로 바꿨어요.

임베딩 입력 =  제목 (×2 반복으로 가중)
            + 설명 앞 500자 (핵심만, 장문 꼬리 잘라냄)
            + 토픽명 (있으면)   ← "다크모드 [UI개선]"

제목을 두 번 넣어 핵심 신호를 강화하고, 설명은 앞부분만 잘라 노이즈를 줄이고, 토픽명을 섞어 카테고리 맥락을 임베딩에 주입했답니다.

원칙 2 — 검색 공간을 메타데이터로 먼저 좁힌다

전체 임베딩을 다 비교하기 전에, 구조적 필터로 후보를 줄이려했어요.

  • organization_id (필수)

  • visibility = 'public', deleted_at IS NULL, merged_into_id IS NULL (이미 정리된 건 제외)

  • status_id가 '완료/취소'인 글은 가중치 하향 (이미 끝난 요청과의 중복은 덜 중요)

  • 같은 토픽 우선 (다른 토픽은 페널티)

원칙 3 — 두 개의 검색 신호를 합친다 (하이브리드 + RRF)

임베딩 하나로는 고유명사를 못 잡았어요. 그래서 시맨틱 검색과 키워드 검색을 병렬로 돌리고, RRF(Reciprocal Rank Fusion)로 순위를 합쳤답니다.

                 ┌── 시맨틱 검색 (pgvector <=>)      → 상위 20개
새 아이디어 ──────┤
                 └── 키워드 검색 (PG tsvector/pg_trgm) → 상위 20개
                          │
                          ▼
                   RRF 결합 (rank 기반 점수 합산)
                          │
                          ▼
                   최종 상위 5개 + 신뢰도 등급

RRF는 점수 스케일이 다른 두 검색을 순위(rank) 기준으로 공정하게 합쳤어요. score = Σ 1/(k + rank_i) (k=60).

"양쪽에서 다 상위권"인 글이 위로 올라오게 되거든요.

왜 reranker는 안 썼나? 자연어용 reranker는 짧은 피드백 문장의 정확한 키워드 매칭을 오히려 뒤로 밀어내는 경향이 있어요.레닛 도메인에선 RRF라는 단순하고 설명 가능한 결합이 더 안정적이라 판단했어요.

원칙 4 — 임계값을 등급으로 바꾼다

단일 컷오프(0.55 above/below)를 버리고, 3단계 신뢰도 등급으로 바꿨답니다. 관리자에게 "중복이다/아니다"를 강요하는 대신 판단 재료를 주는거죠.

등급

결합 점수

UX

거의 확실

≥ 0.82

"이미 등록된 의견일 수 있어요" + 병합 버튼 강조

관련 있음

0.65 ~ 0.82

"비슷한 의견" 접힌 목록으로 제안

약한 연관

0.50 ~ 0.65

노출 안 함 (로그만 기록)

이 4가지 원칙을 기반으로 다음과 같은 방법으로 문제를 해결했어요.

3. 해결 과정


3.1 데이터 계층 — 메타데이터를 임베딩 옆에 둔다

ideas 테이블에 임베딩 컬럼만 있던 걸, 검색에 쓸 메타와 키워드 인덱스를 함께 갖추도록 확장했어요.

-- 시맨틱: HNSW 인덱스 (코사인)
CREATE INDEX idx_ideas_embedding_hnsw
  ON ideas USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

-- 키워드: 한국어 형태소 + trigram 폴백
ALTER TABLE ideas ADD COLUMN search_tsv tsvector;
CREATE INDEX idx_ideas_search_tsv ON ideas USING GIN (search_tsv);
CREATE INDEX idx_ideas_title_trgm ON ideas USING GIN (title gin_trgm_ops);

pgvector(시맨틱)와 GIN/tsvector(키워드)를 같은 테이블에서 돌려, 별도 검색 엔진(Elasticsearch 등) 없이 PostgreSQL 하나로 하이브리드를 구현했어요. 이로써 추가적인 인프라 비용은 0에 수렴했답니다 :)

3.2 검색 계층 — 두 쿼리를 RRF로 합치는 단일 진입점

IdeaSimilarityService.findDuplicates()가 시맨틱·키워드 두 쿼리를 던지고 결과를 RRF로 병합했어요. 핵심 SQL은 이런 모양이에요

WITH semantic AS (
  SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> :q) AS rank
    FROM ideas
   WHERE organization_id = :org AND embedding IS NOT NULL
     AND deleted_at IS NULL AND merged_into_id IS NULL
   ORDER BY embedding <=> :q LIMIT 20
),
keyword AS (
  SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank(search_tsv, :tsq) DESC) AS rank
    FROM ideas
   WHERE organization_id = :org AND search_tsv @@ :tsq
   ORDER BY ts_rank(search_tsv, :tsq) DESC LIMIT 20
)
SELECT id, SUM(1.0/(60 + rank)) AS rrf_score   -- RRF 결합
  FROM (SELECT * FROM semantic UNION ALL SELECT * FROM keyword) u
 GROUP BY id
 ORDER BY rrf_score DESC
 LIMIT 5;

여기에 토픽 일치 보너스/완료-취소 페널티를 애플리케이션 단에서 가중해 최종 등급을 매겼어요.

3.3 최신화 — 변경 즉시 자동 재임베딩

아이디어가 생기거나 수정되면 자동 재임베딩을 해야 했어요. 단, 사용자 응답을 막지 않도록 트랜잭션 커밋 이후 비동기로 처리했습니다.

아이디어 저장 (트랜잭션)
      │ commit
      ▼ afterCommit 콜백
@Async embeddingTaskExecutor
      │  ① 가중 입력 합성  ② OpenAI 임베딩  ③ pgvector UPDATE  ④ tsvector 갱신
      ▼
검색 대상에 즉시 반영

OpenAI 호출이 실패해도 아이디어 생성 자체는 성공해요. 임베딩이 비어 있는 글은 NULL로 남고, 야간 백필 스케줄러가 embedding IS NULL을 주워 담아 신선도 누락을 메우도록 했죠.

3.4 관측 — 임계값을 '감'이 아니라 '로그'로 정한다

가장 중요한 운영 장치. 모든 중복 검색에 대해 borderline 점수 분포를 구조화 로깅처리를 했어요.

[dup-detect] org=acme query="다크모드" 
   top1=0.88(거의확실) top2=0.71(관련) top3=0.52(약함) 
   merged_by_admin=true elapsed=42ms

이 로그가 쌓이자, "관리자가 실제로 병합한 건의 점수 분포"를 보고 등급 경계(0.82 / 0.65)를 데이터로 조정할 수 있었어요. 처음엔 직관으로 0.80을 잡았지만, 운영 한 달치 로그상 관리자 병합의 중앙값이 0.84여서, 등급을 0.82로 미세 조정했어요.


4. 결과

4.1 정량 지표 (운영 보드 4주 측정, before/after)

지표

기존(단일 코사인)

개선(하이브리드+등급)

중복 추천 정밀도

61%

89%

중복 추천 재현율

54%

82%

고유명사 중복 적중률

38%

91%

관리자 수동 병합 시간

주 ~3.5h

주 ~0.8h

평균 검색 지연

31ms

44ms (허용 범위)

추가 인프라 비용

₩0 (PostgreSQL 내 해결)

정밀도가 오르자 관리자가 기능을 신뢰하고 실제로 쓰기 시작한 게 가장 큰 변화였다. 추천을 무시하던 단계에서, 추천대로 병합하는 단계로 넘어갔어요.

4.2 사용자 경험 개선

  • 투표 분산 해소: 같은 요청 3건(43표)이 1건으로 병합되며 우선순위 순위가 또렷해짐

  • 엔드유저 경험: 글 작성 중 "비슷한 의견 있어요"가 떠서, 새 글 대신 기존 글에 투표로 유입 (중복 생성 자체가 감소)

  • 설명 가능성: "왜 이게 중복으로 떴는가"를 시맨틱/키워드 어느 쪽이 잡았는지로 설명 가능

4.3 한계와 다음 단계

  • 한국어 형태소 분석기 품질에 키워드 검색이 좌우됨 → 사용자 사전 보강 필요

  • 등급 경계가 조직마다 다를 수 있음 → 조직별 임계값 자동 학습(관리자 병합 피드백 루프) 검토 중

  • 이미지/스크린샷만 있는 피드백은 임베딩 사각지대 → 멀티모달 임베딩은 추후에 고려할까 생각중


회고

  1. 모델을 키우기 전에 파이프라인을 의심하라 : 레닛에서 문제는 임베딩 모델이 약해서가 아니라, 단일 신호,단일 임계값이라는 구조 때문이었어요. 모델 교체 없이 구조만 바꿔 정밀도를 28%p 올렸답니다.

  2. 입력 설계가 모델 선택보다 효과가 크다 : 제목 가중,토픽 주입 같은 사소한 입력 변경이, 더 비싼 임베딩 모델보다 큰 차이를 만들었어요.

  3. '정확한 단어 일치' 는 임베딩이 약한 영역이다 : 시맨틱과 키워드는 경쟁이 아니라 보완관계에여. RRF는 둘을 싸우지 않게 합치는 가장 단순한 방법이었어여.

  4. 임계값은 감이아니라, 정량적인 데이터로 정하자 : 로그를 먼저 깔고, 데이터가 경계를 정하게 했어요. 직관으로 박은 0.80은 한 달 뒤 0.82로 교정했답니다.

  5. 불필요한 인프라 확장은 하지말자 : 별도 벡터 DB·검색 엔진 없이 pgvector + tsvector만으로 하이브리드 검색을 운영했어요. 작은 팀에는 '인프라를 늘리지 않는 것' 자체가 좋은 설계하로 생각했거든요.