본문 바로가기

AI - 검색의 여정/Step 2 - 찾기

노래 가사 의미검색 실험: 필터 + 벡터 정렬 ( PostgreSQL을 곁들인 )

 

🎯 목표

사랑 관련 노래 중에서, “의미(감정/상황)가 비슷한 가사”를 찾아보고 싶었다.

  • 조건 A: 사랑이 들어가는 가사 중 다시는 없을 최고의 행복 느낌이랑 가까운 가사 찾기
  • 조건 B: 사랑이 들어가는 가사 중 그 어떤 고통보다 더 큰 아픔 느낌이랑 가까운 가사 찾기

핵심은 이거:

  • 🔤 **FTS(키워드 검색)**로 “사랑이 진짜 들어간 문장”만 먼저 걸러놓고
  • 🤔 **벡터 유사도(시맨틱)**로 그 안에서 “내가 원하는 의미”에 가까운 걸 위로 올리기

💽 데이터

  • 사랑 노래 33곡
    • 곡 리스트/아티스트/발매년도/장르 등 33개는 너무 길어서 마지막에 넣겠습니다....

🛠️ 사용 도구(정리)

  • Python
    • 곡/가사 원문 수집 및 1차 정제(개행/특수문자/중복 등)
  • Excel
    • 원문 검수/정리(사람이 보기 좋게)
    • 🧩 청크 분할 규칙 적용 결과 저장
    • 🏷️ 메타데이터 입력 및 보완
  • PostgreSQL (검색 저장소)
    • 기본 역할: 가사 청크 / 메타데이터를 저장하고, 🔤키워드 검색(FTS)🤔벡터 유사도 검색을 한 곳에서 수행
    • 데이터 타입
      • 🔤 tsvector: 가사를 토큰화한 FTS 전용 컬럼(빠른 키워드 필터/랭킹용)
      • 🤔 vector(384): 임베딩(384차원) 저장 컬럼(의미 유사도 검색용)
    • 확장
      • ✅ pgvector: PostgreSQL에 vector 타입과 거리/유사도 연산자, 벡터 인덱스 옵션을 추가
    • 인덱스(성능 최적화, 데이터 커지면 중요)
      • 🔤 tsvector → GIN: chunk_tsv @@ tsquery 같은 FTS 조건을 빠르게 처리
      • 🤔 vector → HNSW / IVFFlat(pgvector): 벡터 유사도 검색 속도/품질 최적화 옵션
  • Hugging Face + DJL
    • DJL: Hugging Face 모델을 (주로 Java에서) 쉽게 로드/추론할 수 있게 해주는 런타임/프레임워크
    • 🧠 sentence-transformers/all-MiniLM-L6-v2: 문장을 384차원 임베딩으로 바꾸는 모델(청크 임베딩 생성에 사용)

노래 정보 저장(원문 수집)

곡 정보

Python으로 곡 정보/가사를 뽑아두고,

Excel에서 사람이 보기 편하게 한 번 정리했다.


🧩 청크 만들기(가사를 검색 단위로 쪼개기)

왜 청크가 중요하냐면, 임베딩은 “한 덩어리 의미”를 벡터로 만들기 때문에
검색 단위(청크)가 애매하면 결과도 애매해지는 느낌이 있다.

그래서 이번엔 이렇게 했다:

  • 청크 규칙: 한국 노래 가사는 2줄이 한 문장처럼 이어지는 경우가 많아서 2줄=1청크로 묶었다.
  • 예시: `이클립스 - 소나기` 가사 中
    • 원문: 그치지 않기를 바랬죠\n처음 그대 내게로 오던 그날에
    • 느낌(의역): “내게로 처음 오던 그날 이 비가 그치지 않길 바랐다” 같은 의미로 이어진다고 봤다.
  • 적용 결과
    • 2줄을 1청크로 저장
    • 곡별로 chunk_index 붙여서 순서도 같이 관리

이번 글은 2줄 기준으로만 해봤고, 1줄/4줄 비교는 아직 안 해봤다(나중에 해보고 싶음).


🏷️ 메타데이터 만들기 ( 필터 / 분석용 )

  • 출시년도 / 출시년월
  • 장르 1 / 장르 2
  • 곡 스타일(발라드/댄스/록 등)
  • 앨범 타입(싱글/EP/정규 등)

데이터가 많아지면 이런 걸로 시대/장르/스타일 필터 같이 걸면 더 편해질 것 같아서 일단 같이 만들어뒀다.


DB 적재 ( 스키마 소개 )

  • songs: 곡 메타 정보 ( 곡명 / 아티스트 / 발매 / 장르 등 )
CREATE TABLE IF NOT EXISTS {스키마}.songs (
	( 생략 )
);

CREATE INDEX IF NOT EXISTS songs_artist_idx      ON {스키마}.songs(artist);
CREATE INDEX IF NOT EXISTS songs_title_idx       ON {스키마}.songs(title);
CREATE INDEX IF NOT EXISTS songs_release_ym_idx  ON {스키마}.songs(release_ym);

 

  • chunks: 가사 청크
    • chunk_text
    • chunk_tsv (🔤 FTS용)
    • embedding (🤔 벡터용, 384)
    • song_id, chunk_index 등
CREATE TABLE IF NOT EXISTS {스키마}.chunks (
	( 생략 )
);

CREATE INDEX IF NOT EXISTS chunks_song_id_idx   ON {스키마}.chunks(song_id);
CREATE INDEX IF NOT EXISTS chunks_tsv_gin_idx   ON {스키마}.chunks USING GIN (chunk_tsv);

🔤 tsvector 생성 ( 청크 → FTS 토큰 )

FTS(키워드 검색)를 쓰려면 chunk_text를 그대로 검색하는 게 아니라,

PostgreSQL이 검색하기 좋은 형태로 토큰화된 컬럼(tsvector) 을 만들어줘야 한다.


그래서 chunk_text를 chunk_tsv로 한 번 변환해서 저장해뒀다.

  • 변환: to_tsvector('simple', coalesce(chunk_text, ''))
    • simple: 가장 단순한 토큰화 설정(형태 변화까지 자동으로 묶어주진 않을 수 있음)
    • coalesce(..., ''): chunk_text가 NULL이어도 안전하게 처리
UPDATE {스키마}.chunks
SET chunk_tsv = to_tsvector('simple', coalesce(chunk_text, ''))
WHERE chunk_tsv IS NULL;

🧠 임베딩 생성 ( 청크 → 벡터 )

청크 텍스트(chunk_text)를 임베딩 모델에 넣어서 384차원 벡터로 바꾼 뒤,

pgvector의 vector(384) 컬럼(chunks.embedding)에 저장했다.


아래 코드는 개념 설명용 예시라서 예외 처리, 모델 캐싱(재사용), 배치 처리 같은 운영 요소는 생략했다.

// Model
private static final String MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2";
private static final String DJL_URL  = "djl://ai.djl.huggingface.pytorch/" + MODEL_ID;

// Build criteria (String -> float[])
Criteria<String, float[]> criteria =
    Criteria.builder()
        .setTypes(String.class, float[].class)
        .optModelUrls(DJL_URL)
        .optTranslatorFactory(new ai.djl.huggingface.translator.TextEmbeddingTranslatorFactory())
        .optEngine("PyTorch")
        .build();

// Predict
try (    ZooModel<String, float[]> model = criteria.loadModel();
    	Predictor<String, float[]> predictor = model.newPredictor();	) {
    String normalized = normalizeForEmbedding(chunkText);	// 전처리
    float[] vec = predictor.predict(normalized);	// 384-d embedding
    PGvector embeddingResult = new PGvector(vec);	// DB vector(384)에 저장하기 위한 형태
    // TODO: embeddingResult를 chunks.embedding 컬럼에 저장
}
- 같은 모델로 문서(청크) 임베딩과 쿼리 임베딩을 만들어야 유사도 검색이 제대로 맞는다.
- 모델을 바꾸면 벡터 공간이 달라져서, 기존 embedding은 다시 생성해야 한다.
- 텍스트 예시로 가사를 너무 길게 붙이는 건(대량 원문) 저작권 이슈가 있을 수 있어,
     본문에는 짧게만 인용하는 쪽이 안전하다.

🔤 + 🤔 검색 준비

🔤 키워드 검색 (FTS)

plainto_tsquery('simple','사랑')
의미: “사랑이 들어간 청크만” 먼저 필터링
참고: simple은 사랑 ↔ 사랑해 같은 형태 변화까지 자동으로 잡아주진 않을 수 있음

SELECT chunk_id, song_id, chunk_index, chunk_text
FROM chunks
WHERE chunk_tsv @@ plainto_tsquery('simple', '사랑')
ORDER BY song_id, chunk_index

 

🤔 시맨틱 검색 (벡터)

embedding <=> query_vec
의미: “쿼리 문장과 의미가 가까운 청크”를 위로

WITH p AS (
  SELECT '[{임베딩 값}]'::vector AS query_vec
)
SELECT c.chunk_id, c.song_id, c.chunk_index, c.chunk_text, (c.embedding <=> p.query_vec) AS Dist
FROM chunks c
cross JOIN p 
WHERE c.embedding IS NOT NULL
ORDER BY c.embedding <=> p.query_vec asc

🔤 + 🤔 같이 검색하기

이번 실험은 FTS로 후보를 제한한 뒤, 그 후보를 벡터 유사도(거리) 기준으로 재정렬했다.


🔤 FTS로 사랑이 포함된 청크만 먼저 추린다. (후보군 제한)
🤔 그 후보군을 대상으로 embedding <=> query_vec 기준으로 가까운 순(거리 낮은 순) 으로 정렬한다. (벡터 기반 재정렬)


정리하면, “키워드로 후보를 줄이고 → 벡터로 순위를 다시 매기는” 방식이다.


(FTS 결과와 벡터 결과를 각각 Top-K로 뽑아 섞는 fusion 방식은 이번 글에서는 사용하지 않았다.)


🔤 + 🤔 결과 확인 ( Top N )

참고로 dist는 거리값이라서 낮을수록 더 비슷한 의미다.
(청크 단위라 같은 곡이 여러 줄로 상위에 같이 뜰 수도 있음)

 

사랑 + “다시는 없을 최고의 행복” 쿼리

  1. 이클립스 - 소나기 (dist: 0.22)
    • 다시는 그대와 같은\n사랑 없을 테니
  2. 이클립스 - 소나기 ( 거리: 0.23 )
    • 그대는 사랑입니다\n하나뿐인 사랑
  3. 다비치 - 이 사랑 ( 거리: 0.27 )
    • 사랑 땜에 나는 살 수 있어

 

사랑 +  “그 어떤 고통보다 더 큰 아픔” 쿼리

  1. 김범수 - 끝사랑 (dist: 0.25)
    • 지금부터 그대 나를 잊고 살아도\n그대만이 영원한 내 사랑
  2. 아이유·HIGH4 - 봄 사랑 벚꽃 말고 ( 거리: 0.26 )
    • 한바탕 휩쓸고 지나가버릴\n오오 봄 사랑 벚꽃 말고
  3. 이승철 - 그런 사람 또 없습니다 ( 거리: 0.29 )
    • 이런 사랑 또 없을테죠\n몰래 감춰둔 오랜 기억속에
  4. 윤종신 - 좋니 ( 거리: 0.29 )
    • 뒤끝 있는 너의 예전 남자친구일 뿐\n스쳤던 그저 그런 사랑
  5. 양파 - 사랑... 그게 뭔데 ( 거리: 0.29 )
    • 이렇게 버릴 걸 나를 가졌니\n사랑 그게 뭔데 이토록 

후기 / 다음에 해볼 것

1️⃣ 청크 길이 가설
청크가 너무 짧아지면 사랑이 안 들어간 청크가 늘고 → FTS 단계에서 후보군이 바뀌어서 결과도 달라질 것 같다.

2️⃣ 메타데이터 활용
장르/시대 필터 걸어두면 결과 보는 것도 훨씬 편해질 것 같다.

3️⃣ FTS 쿼리/설정 개선
simple + plainto_tsquery('사랑')로는 사랑해/사랑했던 같은 변형을 놓칠 수 있어서, 

to_tsquery('사랑:*') 같은 방식도 같이 봐야 할 것 같고 여러 쿼리를 공부해야 할 것 같다.

4️⃣ 임베딩 모델/차원 변경
임베딩 모델을 바꾸면 결과가 얼마나 바뀔지 궁금하다.
(모델 바꾸면 기존 embedding은 다시 만들어야 함)

5️⃣ 결합 ( Fusion ) + 추가 리랭킹
키워드 결과와 시맨틱 결과를 점수/랭크로 섞는 방식(RRF 등)이나, 

별도 리랭커(크로스 인코더)를 적용하면 결과가 더 좋아질지 궁금하다.


사용 노래 및 참고 : https://www.youtube.com/watch?v=RJjdpMM3KQM

  • 플라워 - Endless
    테이 - 같은 베개
    더원 - 사랑아
    캔 - 천상연
    한동근 - 그대라는 사치
    백지영 - 잊지 말아요
    정승환 - 너였다면
    에이치코드 - 꿈속에 너
    김광석 - 사랑했지만
    모세 - 사랑인걸
    이선희 - 인연
    유리상자 - 사랑해도 될까요
    다비치 - 이 사랑
    박상민 - 해바라기
    윤종신 - 좋니
    임창정 - 내가 저지른 사랑
    아이유 - 봄 사랑 벚꽃 말고
    김정민 - 무한지애
    김종국 - 사랑스러워
    양파 - 사랑... 그게 뭔데
    김범수 - 끝사랑
    이승철 - 그런 사람 또 없습니다
    버즈 - My Love
    스탠딩 에그 - 오래된 노래
    윤도현 - 사랑했나봐
    성시경 - 너의 모든 순간
    김장훈 - Honey
    M.C THE MAX - One Love
    팀 - 사랑합니다
    캔 - 사랑해서 미안합니다
    이클립스 - 소나기
    안예은 - 상사화
    안예은 - 낮에 뜨는 달