
🎯 목표
사랑 관련 노래 중에서, “의미(감정/상황)가 비슷한 가사”를 찾아보고 싶었다.
- 조건 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는 거리값이라서 낮을수록 더 비슷한 의미다.
(청크 단위라 같은 곡이 여러 줄로 상위에 같이 뜰 수도 있음)
사랑 + “다시는 없을 최고의 행복” 쿼리
- 이클립스 - 소나기 (dist: 0.22)
- 다시는 그대와 같은\n사랑 없을 테니
- 이클립스 - 소나기 ( 거리: 0.23 )
- 그대는 사랑입니다\n하나뿐인 사랑
- 다비치 - 이 사랑 ( 거리: 0.27 )
- 사랑 땜에 나는 살 수 있어
사랑 + “그 어떤 고통보다 더 큰 아픔” 쿼리
- 김범수 - 끝사랑 (dist: 0.25)
- 지금부터 그대 나를 잊고 살아도\n그대만이 영원한 내 사랑
- 아이유·HIGH4 - 봄 사랑 벚꽃 말고 ( 거리: 0.26 )
- 한바탕 휩쓸고 지나가버릴\n오오 봄 사랑 벚꽃 말고
- 이승철 - 그런 사람 또 없습니다 ( 거리: 0.29 )
- 이런 사랑 또 없을테죠\n몰래 감춰둔 오랜 기억속에
- 윤종신 - 좋니 ( 거리: 0.29 )
- 뒤끝 있는 너의 예전 남자친구일 뿐\n스쳤던 그저 그런 사랑
- 양파 - 사랑... 그게 뭔데 ( 거리: 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
팀 - 사랑합니다
캔 - 사랑해서 미안합니다
이클립스 - 소나기
안예은 - 상사화
안예은 - 낮에 뜨는 달
'AI - 검색의 여정 > Step 2 - 찾기' 카테고리의 다른 글
| AI 검색: 하나의 질문이 정답 후보를 찾아가는 여정 (0) | 2026.01.12 |
|---|---|
| 🧪 평가/개선(Evaluation & Improvement): 좋아졌는지 '증명'하고 다음 개선으로 연결하기 (0) | 2026.01.09 |
| 🎯 리랭킹(Re-ranking): 후보를 다시 읽고 '진짜 순서'로 정렬하기 (0) | 2026.01.08 |
| ⚖️ 결합 로직: 후보군을 '공정하게' 합치는 하이브리드의 핵심 (Linear vs RRF) (1) | 2026.01.06 |
| 🤔 의미 검색(시멘틱 서치): 임베딩으로 후보군을 만드는 두 번째 축 (0) | 2026.01.05 |