목차
- 사용 라이브러리 및 기본 설정
- 검색 기법 복습 (키워드 / 벡터 / 하이브리드)
- RRF (Reciprocal Rank Fusion)
- 검색 평가 지표 (Precision@K, Recall@K, MRR)
- 그리드 서치로 최적 가중치 탐색
- 한국어 형태소 분석 (Kiwi)
- 쿼리 확장 (Query Expansion)
- Multi-Query 기법
- 쿼리 다양성 측정 (Query Diversity)
- HyDE (Hypothetical Document Embeddings)
- 자주 나오는 실수 / 주의사항
- [보충] 검색 고급 기법 비교
1. 사용 라이브러리 및 기본 설정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| import os
import math
import numpy as np
import warnings
from collections import Counter
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
from langchain_classic.retrievers import EnsembleRetriever
from sentence_transformers import SentenceTransformer
from kiwipiepy import Kiwi
load_dotenv()
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
llm = ChatOpenAI(model="gpt-4o-mini")
|
필수 설치: pip install rank_bm25 kiwipiepy sentence-transformers
샘플 문서 및 FAISS 설정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| documents = [
"Python은 데이터 과학과 머신러닝에 널리 사용되는 프로그래밍 언어입니다.",
"자연어 처리(NLP)는 컴퓨터가 인간의 언어를 이해하고 생성하는 기술입니다.",
"벡터 데이터베이스는 고차원 벡터를 효율적으로 저장하고 검색하는 시스템입니다.",
"GPT-4는 OpenAI가 개발한 대규모 언어 모델로 다양한 작업을 수행합니다.",
"RAG(검색 증강 생성)는 외부 지식을 활용하여 LLM 응답을 개선합니다.",
"FastAPI는 Python으로 빠른 웹 API를 구축하기 위한 프레임워크입니다.",
"트랜스포머 아키텍처는 어텐션 메커니즘을 활용한 딥러닝 모델 구조입니다.",
"FAISS는 Facebook AI가 개발한 효율적인 유사도 검색 라이브러리입니다.",
"프롬프트 엔지니어링은 LLM에 효과적인 입력을 설계하는 기술입니다.",
"임베딩은 텍스트를 수치 벡터로 변환하여 의미적 유사성을 측정할 수 있게 합니다.",
]
docs_lc = [Document(page_content=d, metadata={"doc_id": i}) for i, d in enumerate(documents)]
vectorstore = FAISS.from_documents(docs_lc, embeddings)
vector_retriever = vectorstore.as_retriever(search_kwargs={'k': 3})
bm25_retriever = BM25Retriever.from_documents(docs_lc)
bm25_retriever.k = 3
|
FAISS 검색 래퍼 함수
1
2
3
4
5
6
7
8
9
10
| def _score(distance):
return 1.0 / (1.0 + float(distance))
def search_faiss(query, top_k=3):
results = vectorstore.similarity_search_with_score(query, k=top_k)
return [(doc.metadata['doc_id'], _score(dist)) for doc, dist in results]
def search_by_vector(embedding, top_k=3):
results = vectorstore.similarity_search_with_score_by_vector(embedding, k=top_k)
return [(doc.metadata['doc_id'], _score(dist)) for doc, dist in results]
|
1
2
3
| 출력 예시 ("Python 프로그래밍"):
[(0, 0.491), (5, 0.432), (3, 0.405)]
→ (문서 인덱스, 유사도 점수)
|
2. 검색 기법 복습 (키워드 / 벡터 / 하이브리드)
이전 강의(04_02) 내용을 바탕으로 검색기를 준비합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| # 키워드 검색
def keyword_search(query, docs, top_k=3):
query_tokens = set(query.lower().split())
scores = [(i, len(query_tokens & set(doc.lower().split()))) for i, doc in enumerate(docs)]
return sorted(scores, key=lambda x: x[1], reverse=True)[:top_k]
# 벡터 검색 (코사인 유사도)
def vector_search(query, docs, doc_embs, top_k=3):
q_emb = np.array(embeddings.embed_query(query))
similarities = np.dot(doc_embs, q_emb) / (np.linalg.norm(doc_embs, axis=1) * np.linalg.norm(q_emb))
return [(i, similarities[i]) for i in similarities.argsort()[::-1][:top_k]]
# 단순 하이브리드 (점수 정규화 후 합산)
def simple_hybrid(query, docs, doc_embs, top_k=3):
kw = keyword_search(query, docs, top_k=len(docs))
vec = vector_search(query, docs, np.array(doc_embs), top_k=len(docs))
kw_max = max(s for _, s in kw) or 1
vec_max = max(s for _, s in vec) or 1
kw_scores = {idx: score/kw_max for idx, score in kw}
vec_scores = {idx: score/vec_max for idx, score in vec}
combined = {idx: kw_scores.get(idx,0) + vec_scores.get(idx,0) for idx in range(len(docs))}
return sorted(combined.items(), key=lambda x: x[1], reverse=True)[:top_k]
|
1
2
3
4
5
| 하이브리드 검색 결과 예시:
"Python 프로그래밍 언어":
[0] 2.000 | Python은 데이터 과학과 머신러닝에 널리 사용되는...
[3] 1.652 | GPT-4는 OpenAI가 개발한 대규모 언어 모델로...
[1] 0.673 | 자연어 처리(NLP)는 컴퓨터가 인간의 언어를...
|
3. RRF (Reciprocal Rank Fusion)
왜 쓰나?
단순 점수 합산은 점수 스케일 차이(BM25: 0~4, 코사인: 0~1)에 민감함. RRF는 순위(rank) 만 사용하므로 스케일에 영향을 받지 않고, 이상치에도 강건함.
공식
1
2
3
4
5
6
| RRF_score(doc) = Σ 1 / (k + rank_i)
- rank_i: i번째 검색 결과에서 해당 문서의 순위 (1부터 시작)
- k: 평탄화 파라미터 (기본값 60)
→ k 크면: 순위 차이가 완화됨 (1위와 60위의 점수 차이가 작아짐)
→ k 작으면: 상위 순위에 더 강한 가중치
|
구현 코드
1
2
3
4
5
6
7
8
9
10
| def rrf(rankings, k=60):
"""
rankings: 여러 검색기의 결과 리스트
각 결과는 [(doc_id, score), ...] 형태
"""
rrf_scores = {}
for ranking in rankings:
for rank, (doc_id, _) in enumerate(ranking, start=1):
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
|
사용 예시
1
2
3
4
5
6
7
8
9
| bm25 = BM25(documents)
tfidf = TFIDF(documents)
query = "대규모 언어 모델"
bm25_results = bm25.search(query, top_k=5)
tfidf_results = tfidf.search(query, top_k=5)
vec_results = vector_search(query, documents, np.array(doc_embeddings), top_k=5)
combined = rrf([bm25_results, tfidf_results, vec_results], k=60)
|
1
2
3
4
5
6
7
8
9
10
11
12
| BM25 결과:
1위: [3] GPT-4는 OpenAI가 개발한 대규모 언어 모델로 (score=3.861)
2위: [6] 트랜스포머 아키텍처는 어텐션 메커니즘을 활용한 (score=2.036)
벡터 결과:
1위: [3] GPT-4는 OpenAI가 개발한 대규모 언어 모델로 (score=0.532)
2위: [1] 자연어 처리(NLP)는 컴퓨터가 인간의 언어를 (score=0.303)
RRF 결합 결과:
1위: [3] score=0.0328 ← 두 검색기 모두 1위
2위: [6] score=0.0323 ← BM25 2위 + 벡터 3위
3위: [1] score=0.0161 ← 벡터 2위만 반영
|
4. 검색 평가 지표 (Precision@K, Recall@K, MRR)
구현 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| def precision_at_k(retrieved, relevant, k):
"""상위 K개 중 정답의 비율"""
top_k = retrieved[:k]
return len(set(top_k) & set(relevant)) / k
def recall_at_k(retrieved, relevant, k):
"""전체 정답 중 상위 K개에서 찾은 비율"""
top_k = retrieved[:k]
return len(set(top_k) & set(relevant)) / len(relevant) if relevant else 0
def mrr(retrieved, relevant):
"""첫 번째 정답 문서까지의 순위 역수"""
for rank, doc_id in enumerate(retrieved, 1):
if doc_id in relevant:
return 1.0 / rank
return 0.0
|
사용 예시
1
2
3
4
5
6
| retrieved = [3, 1, 4, 0, 2] # 검색된 문서 순서 (doc_id)
relevant = [4, 0] # 실제 정답 문서
print(precision_at_k(retrieved, relevant, k=3)) # 0.333 (3개 중 1개 정답)
print(recall_at_k(retrieved, relevant, k=3)) # 0.5 (2개 중 1개 찾음)
print(mrr(retrieved, relevant)) # 0.333 (첫 정답이 3위 → 1/3)
|
MRR 해석
1
2
3
4
5
6
7
| MRR (Mean Reciprocal Rank):
- 첫 번째로 나오는 정답 문서의 순위 역수
- 순위 1위에 정답 → MRR = 1.0
- 순위 3위에 정답 → MRR = 0.33
- 정답 없음 → MRR = 0.0
여러 쿼리의 MRR 평균 → 검색 시스템의 전반적 성능 측정
|
5. 그리드 서치로 최적 가중치 탐색
왜 쓰나?
하이브리드 검색의 weights=[벡터, BM25] 가중치를 어떻게 설정하느냐에 따라 성능이 달라짐. 모든 가중치 조합을 실험해서 MRR이 최대인 지점을 찾는 방법.
평가 데이터셋
1
2
3
4
5
6
7
8
9
| eval_dataset = [
{"query": "Python 프로그래밍 언어", "relevant": [0, 5]},
{"query": "자연어 처리 NLP 기술", "relevant": [1]},
{"query": "벡터 검색 유사도", "relevant": [2, 7]},
{"query": "대규모 언어 모델", "relevant": [3]},
{"query": "검색 증강 생성 RAG", "relevant": [4]},
{"query": "딥러닝 모델 구조", "relevant": [6]},
{"query": "프롬프트 설계 기법", "relevant": [8]},
]
|
그리드 서치 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| def hybrid_search(query, w_bm25, top_k=5):
"""가중치를 바꿔가며 하이브리드 검색"""
vec_results = search_faiss(query, top_k=top_k)
bm25_results = [(r.metadata['doc_id'], 1.0) for r in bm25_retriever.invoke(query)]
vec_max = max(s for _, s in vec_results) or 1
bm25_max = max(s for _, s in bm25_results) if bm25_results else 1
combined = {}
for doc_id, score in vec_results:
combined[doc_id] = combined.get(doc_id, 0) + (1 - w_bm25) * score / vec_max
for doc_id, score in bm25_results:
combined[doc_id] = combined.get(doc_id, 0) + w_bm25 * score / bm25_max
return [doc_id for doc_id, _ in sorted(combined.items(), key=lambda x: x[1], reverse=True)]
# 가중치 0.0 ~ 1.0 범위에서 탐색 (0.1 간격)
results = []
for w in np.arange(0, 1.05, 0.1):
mrr_sum = sum(mrr(hybrid_search(item['query'], w_bm25=w), item['relevant'])
for item in eval_dataset)
results.append((round(w, 1), mrr_sum / len(eval_dataset)))
best_w, best_mrr = max(results, key=lambda x: x[1])
|
1
2
3
4
5
6
7
8
9
| 탐색 결과:
w_bm25 = 0.0 → MRR = 1.000 ← 최적 (벡터 100%)
w_bm25 = 0.1 → MRR = 0.905
w_bm25 = 0.5 → MRR = 0.833
w_bm25 = 1.0 → MRR = 0.786 ← BM25 100%
해석:
이 데이터셋에서는 벡터 검색 비율이 높을수록 성능이 좋음
도메인/데이터에 따라 결과는 달라지므로 반드시 직접 실험 필요
|
6. 한국어 형태소 분석 (Kiwi)
왜 쓰나?
한국어는 조사/어미가 붙어 있어 단순 split()으로는 의미 단위 분리 불가. 형태소 분석기로 의미 있는 단어(명사) 단위로 쪼개면 BM25/키워드 검색 성능 향상.
1
2
3
4
5
6
| from kiwipiepy import Kiwi
kiwi = Kiwi()
query = '자연어 처리는 어렵습니다'
tokens = kiwi.tokenize(query)
|
1
2
3
4
5
| 출력:
Token(form='자연어 처리', tag='NNP', ...) ← 고유명사
Token(form='는', tag='JX', ...) ← 보조사
Token(form='어렵', tag='VA', ...) ← 형용사
Token(form='습니다', tag='EF', ...) ← 종결어미
|
명사만 추출하여 쿼리 전처리
1
2
3
4
5
6
7
| def extract_nouns(text):
tokens = kiwi.tokenize(text)
return [t.form for t in tokens if t.tag in ['NNP', 'NNG']]
# NNP: 고유명사, NNG: 일반명사
nouns = extract_nouns("자연어 처리는 어렵습니다")
# 출력: ['자연어 처리']
|
품사 태그 주요 종류
| 태그 | 의미 | 예시 |
|---|
| NNG | 일반명사 | 컴퓨터, 언어 |
| NNP | 고유명사 | Python, OpenAI |
| VV | 동사 | 사용하다, 처리하다 |
| VA | 형용사 | 어렵다, 빠르다 |
| JX | 보조사 | 은, 는, 이, 가 |
| EF | 종결어미 | 합니다, 입니다 |
7. 쿼리 확장 (Query Expansion)
왜 쓰나?
원래 쿼리 하나만으로는 표현 차이 때문에 관련 문서를 놓칠 수 있음. 여러 관련 쿼리를 같이 검색하여 더 많은 관련 문서를 찾는 기법.
단순 확장 (Simple Expansion)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| def search_with_expansion(original, expanded_list, top_k=5):
"""원래 쿼리 + 확장 쿼리 모두 검색, 최고 점수만 채택"""
all_results = {}
for q in [original] + expanded_list:
for doc_id, score in search_faiss(q, top_k=top_k):
# 같은 문서가 여러 쿼리에서 나오면 최고 점수 유지
if doc_id not in all_results or score > all_results[doc_id]:
all_results[doc_id] = score
return sorted(all_results.items(), key=lambda x: x[1], reverse=True)[:top_k]
# 사용 예
original = "AI 챗봇 답변 품질 높이기"
expanded = [
"RAG 검색 증강 생성 기법",
"프롬프트 엔지니어링 LLM 입력 설계",
]
results = search_with_expansion(original, expanded, top_k=5)
|
RRF 기반 확장
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| def search_with_rrf(queries, top_k=3, k=60):
"""여러 쿼리의 검색 결과를 RRF로 통합"""
rrf_scores = {}
for query in queries:
results = search_faiss(query, top_k=5)
for rank, (doc_id, _) in enumerate(results, 1):
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
queries = [
"AI 챗봇 답변 품질 높이기",
"RAG 검색 증강 생성 기법",
"프롬프트 엔지니어링 LLM 입력 설계",
]
results = search_with_rrf(queries)
|
1
2
3
| 단순 확장 vs RRF 기반 확장의 차이:
단순 확장: 쿼리별 최고 점수를 사용 → 점수 스케일 민감
RRF 기반: 순위만 사용 → 스케일 무관, 여러 쿼리에서 자주 등장한 문서 우대
|
8. Multi-Query 기법
왜 쓰나?
수동으로 확장 쿼리를 만들지 않고 LLM이 자동으로 원래 쿼리를 다양한 관점에서 재작성. 사용자 의도를 유지하면서 표현 다양성 확보.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| MULTI_QUERY_PROMPT = """주어진 질문을 3가지 서로 다른 관점에서 재작성하세요.
각 쿼리는 원래 질문의 의도를 유지하되, 다른 단어와 표현을 사용하세요.
원래 질문: {query}
재작성1:
재작성2:
재작성3:"""
def generate_multi_queries(query, n=3):
response = llm.invoke(MULTI_QUERY_PROMPT.format(query=query)).content
queries = [line.strip() for line in response.strip().split('\n') if line.strip()]
return queries[:n]
def multi_query_search(query, top_k=3):
expanded = generate_multi_queries(query, n=3)
all_queries = [query] + expanded
return search_with_rrf(all_queries, top_k=top_k)
|
1
2
3
4
5
6
7
8
| 사용 예:
원래 쿼리: "벡터검색"
LLM 재작성:
재작성1: "벡터 기반 검색"
재작성2: "벡터를 활용한 검색 방법"
재작성3: "임베딩 벡터 유사도 검색"
→ 4개 쿼리를 모두 검색 후 RRF로 통합
|
전체 흐름
1
2
3
4
5
6
7
8
9
| 원래 쿼리
↓
LLM으로 n가지 재작성 (generate_multi_queries)
↓
모든 쿼리 검색 (search_faiss × n+1)
↓
RRF로 순위 통합 (search_with_rrf)
↓
최종 결과 반환
|
9. 쿼리 다양성 측정 (Query Diversity)
왜 쓰나?
LLM이 생성한 확장 쿼리들이 실제로 서로 다른 관점인지 확인. 비슷한 쿼리만 있으면 확장 효과가 없으므로, 다양성이 높아야 효과적.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| def query_diversity(queries):
if len(queries) < 2:
return 0
embs = np.array(embeddings.embed_documents(queries))
norms = np.linalg.norm(embs, axis=1, keepdims=True)
embs_norm = embs / norms # 단위 벡터로 정규화
# 모든 쿼리 쌍의 코사인 유사도 평균
total_sim, count = 0, 0
for i in range(len(queries)):
for j in range(i+1, len(queries)):
total_sim += np.dot(embs_norm[i], embs_norm[j])
count += 1
avg_sim = total_sim / count
return 1 - avg_sim # 다양성 = 1 - 유사도
|
1
2
3
4
5
6
7
8
9
10
| 사용 예:
다양한 쿼리: ["Python 웹 개발", "딥러닝 모델 구조", "데이터베이스 설계"]
→ diversity = 0.795 (높음)
비슷한 쿼리: ["Python 웹 개발", "Python 웹 프레임워크", "Python 웹 서버"]
→ diversity = 0.180 (낮음)
해석:
diversity 값이 낮으면 → 쿼리 재생성 다시 시도하거나 다른 프롬프트 사용
diversity 값이 높으면 → 다양한 관점의 검색 결과 기대 가능
|
10. HyDE (Hypothetical Document Embeddings)
개념
일반 벡터 검색은 질문 임베딩과 문서 임베딩을 비교. 질문과 문서는 문장 패턴이 달라서 임베딩 공간에서 거리가 멀 수 있음.
HyDE는 질문을 그대로 임베딩하지 않고:
- LLM으로 가상 답변(Hypothesis) 을 생성
- 가상 답변의 임베딩으로 문서 검색
1
2
3
4
| 일반 검색: 질문 임베딩 → 문서 임베딩 비교
HyDE: 질문 → LLM → 가상 답변 → 가상 답변 임베딩 → 문서 임베딩 비교
↑
문서와 비슷한 패턴 → 더 정확한 검색 기대
|
구현 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| HYDE_PROMPT = """아래 질문에 대해 3~4문장으로 답변을 작성해주세요.
정확하지 않아도 괜찮습니다. 관련 주제의 문서처럼 작성하세요.
질문: {query}
답변:"""
def generate_hypothesis(query):
"""질문에 대한 가상 답변 생성"""
return llm.invoke(HYDE_PROMPT.format(query=query)).content
def hyde_search(query, top_k=3):
"""가상 답변 임베딩으로 문서 검색"""
hypothesis = generate_hypothesis(query)
hyp_emb = embeddings.embed_query(hypothesis) # 가상 답변을 임베딩
return search_by_vector(hyp_emb, top_k) # 벡터로 직접 검색
|
실행 예시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| query = "RAG란 무엇인가요?"
hypothesis = generate_hypothesis(query)
# LLM 생성 가상 답변:
# "RAG는 'Retrieval-Augmented Generation'의 약자로, 인공지능 모델이
# 외부 지식 베이스에서 관련 정보를 검색한 후 그 내용을 바탕으로
# 답변을 생성하는 방식입니다. ..."
# HyDE 검색 결과
hyde_results = hyde_search(query)
# [(4, 0.566), (1, 0.470), (3, 0.435)]
# ↑ RAG 문서가 1위
# 일반 벡터 검색 결과
normal_results = search_faiss(query)
# [(4, 0.518), (10, 0.387), (7, 0.381)]
# ↑ RAG 문서 1위이지만 2~3위 결과 다름
|
1
2
3
4
5
6
7
| HyDE 장점:
- 질문 패턴 ≠ 문서 패턴 문제 완화
- 답변 형식의 텍스트 → 문서와 유사한 임베딩 공간
HyDE 단점:
- LLM 호출 비용 추가
- 가상 답변이 엉뚱하면 검색 결과도 엉뚱해질 수 있음
|
11. 자주 나오는 실수 / 주의사항
- RRF의 k 값: 기본값 60. 너무 작으면 1위에 과도한 가중치, 너무 크면 순위 효과 희석
- 그리드 서치 결과는 데이터셋에 종속: 샘플 데이터에서 최적이어도 실제 데이터에서는 다를 수 있음 → 반드시 대표적인 평가셋 구성
- MRR은 첫 정답만 반영: 정답이 여러 개일 때는 MAP(Mean Average Precision) 함께 사용 권장
- Multi-Query 프롬프트 설계 중요: “재작성1:”, “재작성2:” 형식이 LLM마다 다를 수 있음 → 파싱 오류 주의
- HyDE는 LLM 환각(Hallucination) 위험: 가상 답변이 완전히 틀리면 오히려 검색 성능 저하
- Kiwi 형태소 분석 후 BM25 연동:
BM25Retriever는 기본 공백 분리를 사용하므로, 한국어 최적화 시 커스텀 토크나이저 전달 필요
[보충] 검색 고급 기법 비교
기법별 특징 요약
| 기법 | 핵심 아이디어 | 장점 | 단점 |
|---|
| 단순 하이브리드 | 점수 정규화 후 합산 | 구현 간단 | 점수 스케일 민감 |
| RRF | 순위 기반 통합 | 스케일 무관, 이상치 강건 | 동점 처리 필요 |
| 쿼리 확장 | 여러 쿼리 검색 후 통합 | 표현 다양성 보완 | 쿼리 수 × 검색 비용 |
| Multi-Query | LLM이 자동 재작성 | 자동화, 다양한 관점 | LLM 비용, 파싱 불안정 |
| HyDE | 가상 답변 임베딩 검색 | 질문-문서 패턴 불일치 해소 | LLM 비용, 환각 위험 |
검색 파이프라인 전체 흐름
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| 사용자 질문
↓
[쿼리 전처리]
형태소 분석 (Kiwi) → 명사 추출
↓
[쿼리 확장]
Multi-Query (LLM 재작성) 또는 수동 확장
↓
[검색 실행]
BM25 검색 + FAISS 벡터 검색 (또는 HyDE)
↓
[결과 통합]
RRF 또는 가중치 합산 (최적 가중치는 그리드 서치로 탐색)
↓
[평가]
Precision@K, Recall@K, MRR
|
끝