목차
- 사용 라이브러리 및 초기 설정
- 임베딩 모델 비교 (OpenAI vs Sentence Transformers)
- 키워드 검색
- 벡터 검색
- TF-IDF 검색
- BM25 검색
- LangChain BM25 Retriever
- FAISS 벡터 스토어
- Ensemble Retriever (하이브리드 검색)
- 자주 나오는 실수 / 주의사항
- [보충] 검색 기술 비교 및 선택 가이드
1. 사용 라이브러리 및 초기 설정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| 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
|
- rank_bm25 미설치 시 BM25Retriever import 오류 발생 pip install rank_bm25 로 설치 필요
샘플 문서 (10개)
1
2
3
4
5
6
7
8
9
10
11
12
| documents = [
"Python은 데이터 과학과 머신러닝에 널리 사용되는 프로그래밍 언어입니다.",
"자연어 처리(NLP)는 컴퓨터가 인간의 언어를 이해하고 생성하는 기술입니다.",
"벡터 데이터베이스는 고차원 벡터를 효율적으로 저장하고 검색하는 시스템입니다.",
"GPT-4는 OpenAI가 개발한 대규모 언어 모델로 다양한 작업을 수행합니다.",
"RAG(검색 증강 생성)는 외부 지식을 활용하여 LLM 응답을 개선합니다.",
"FastAPI는 Python으로 빠른 웹 API를 구축하기 위한 프레임워크입니다.",
"트랜스포머 아키텍처는 어텐션 메커니즘을 활용한 딥러닝 모델 구조입니다.",
"FAISS는 Facebook AI가 개발한 효율적인 유사도 검색 라이브러리입니다.",
"프롬프트 엔지니어링은 LLM에 효과적인 입력을 설계하는 기술입니다.",
"임베딩은 텍스트를 수치 벡터로 변환하여 의미적 유사성을 측정할 수 있게 합니다.",
]
|
OpenAI Embeddings
1
2
3
4
5
| embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
doc_embeddings = embeddings.embed_documents(documents)
# 반환값은 list → shape 확인 시 np.array로 변환 필요
np.array(doc_embeddings).shape # (10, 1536)
|
1
2
3
4
5
| from sentence_transformers import SentenceTransformer
embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
embedding = embedding_model.encode(documents)
embedding.shape # (10, 384)
|
비교표
| 항목 | OpenAI (text-embedding-3-small) | Sentence Transformers (all-MiniLM-L6-v2) |
|---|
| 차원 | 1536 | 384 |
| 실행 위치 | API (클라우드) | 로컬 |
| 비용 | API 비용 발생 | 무료 |
| 성능 | 높음 | 경량, 빠름 |
| 설치 | langchain_openai | pip install sentence-transformers |
3. 키워드 검색
가장 단순한 검색 방식. 쿼리와 문서의 단어 교집합 크기로 점수를 계산합니다.
1
2
3
4
5
6
7
8
9
10
11
| def keyword_search(query, docs, top_k=3):
query_tokens = set(query.lower().split())
scores = []
for i, doc in enumerate(docs):
doc_tokens = set(doc.lower().split())
overlap = len(query_tokens & doc_tokens) # 교집합 크기
scores.append((i, overlap))
scores.sort(key=lambda x: x[1], reverse=True)
return scores[:top_k]
results = keyword_search("Python 프로그래밍 언어", documents)
|
1
2
| 장점: 매우 빠름, 구현 단순
단점: 의미 무시 ("자동차" ≠ "차량"), 문법 변형에 민감
|
4. 벡터 검색
임베딩 벡터 간의 코사인 유사도로 의미적 유사성을 계산합니다.
1
2
3
4
5
6
7
8
9
10
11
12
| def vector_search(query, docs, doc_embs, top_k=3):
q_emb = np.array(embeddings.embed_query(query))
doc_embs_np = np.array(doc_embs)
# 코사인 유사도: dot(a, b) / (||a|| * ||b||)
similarities = np.dot(doc_embs_np, q_emb) / (
np.linalg.norm(doc_embs_np, axis=1) * np.linalg.norm(q_emb)
)
top_indices = similarities.argsort()[::-1][:top_k]
return [(i, similarities[i]) for i in top_indices]
results = vector_search("Python 프로그래밍 언어", documents, doc_embeddings, top_k=3)
|
1
2
| 장점: 의미적 유사성 포착 ("자동차" ≈ "차량")
단점: 계산 비용 높음, 예상치 못한 의미 연결 가능
|
키워드 검색 vs 벡터 검색 겹침률
1
2
3
4
5
6
| def overlap_rate(keyword_results, vector_results):
kw_ids = set(idx for idx, _ in keyword_results)
vec_ids = set(idx for idx, _ in vector_results)
overlap = kw_ids & vec_ids
union = kw_ids | vec_ids
return len(overlap) / len(union)
|
5. TF-IDF 검색
단어의 상대적 중요도를 반영한 검색 방식입니다.
핵심 공식
1
2
3
4
5
6
7
8
9
10
| TF (Term Frequency):
TF(t, d) = f(t, d) / |d|
→ 문서 d에서 단어 t의 등장 빈도 / 문서 길이
IDF (Inverse Document Frequency):
IDF(t) = log( N / df(t) )
→ N: 전체 문서 수, df(t): t가 등장한 문서 수
TF-IDF(t, d) = TF(t, d) × IDF(t)
→ 이 문서에서 자주 나오면서, 전체에서는 드물수록 중요
|
구현 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| import math
class TFIDF:
def __init__(self, documents):
self.docs = documents
self.tokenized = [doc.lower().split() for doc in documents]
self.N = len(documents)
self.df = {}
for tokens in self.tokenized:
for t in set(tokens):
self.df[t] = self.df.get(t, 0) + 1
def tf(self, term, doc_tokens):
return doc_tokens.count(term) / len(doc_tokens)
def idf(self, term):
return math.log(self.N / self.df.get(term, 1))
def score(self, query, doc_idx):
tokens = self.tokenized[doc_idx]
return sum(self.tf(t, tokens) * self.idf(t) for t in query.lower().split())
def search(self, query, top_k=3):
scores = [(i, self.score(query, i)) for i in range(self.N)]
return sorted(scores, key=lambda x: x[1], reverse=True)[:top_k]
tfidf = TFIDF(documents)
results = tfidf.search('Python 프로그래밍')
|
6. BM25 검색
TF-IDF의 개선 버전. 실무에서 가장 널리 쓰이는 키워드 기반 랭킹 알고리즘입니다.
핵심 공식
1
2
3
4
5
6
7
8
9
| BM25(t, d) = IDF(t) × [ tf × (k1 + 1) ] / [ tf + k1 × (1 - b + b × dl/avgdl) ]
파라미터:
- k1: 단어 빈도 포화도 제어 (기본값 1.5)
→ k1 높을수록 빈도 증가의 효과 더 오래 지속
- b: 문서 길이 정규화 강도 (기본값 0.75)
→ b=1: 문서 길이 완전 정규화, b=0: 정규화 없음
- dl: 현재 문서의 길이
- avgdl: 전체 문서의 평균 길이
|
TF-IDF와의 차이점
1
2
| TF-IDF: 단어가 많이 나올수록 점수가 선형적으로 증가
BM25: 단어 빈도가 일정 수준 이상이면 점수 증가가 포화됨 (더 현실적)
|
구현 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| class BM25:
def __init__(self, documents, k1=1.5, b=0.75):
self.k1, self.b = k1, b
self.docs = documents
self.tokenized = [doc.lower().split() for doc in documents]
self.N = len(documents)
self.avgdl = sum(len(d) for d in self.tokenized) / self.N
self.df = {}
for tokens in self.tokenized:
for t in set(tokens):
self.df[t] = self.df.get(t, 0) + 1
def idf(self, term):
df = self.df.get(term, 0)
return math.log((self.N - df + 0.5) / (df + 0.5) + 1)
def score(self, query, doc_idx):
tokens = self.tokenized[doc_idx]
dl = len(tokens)
tf_counter = Counter(tokens)
total = 0.0
for t in query.lower().split():
tf = tf_counter.get(t, 0)
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (1 - self.b + self.b * dl / self.avgdl)
total += self.idf(t) * numerator / denominator
return total
def search(self, query, top_k=3):
scores = [(i, self.score(query, i)) for i in range(self.N)]
return sorted(scores, key=lambda x: x[1], reverse=True)[:top_k]
bm25 = BM25(documents)
results = bm25.search('Python 프로그래밍')
|
7. LangChain BM25 Retriever
직접 구현하지 않고 LangChain의 내장 BM25를 사용하는 방법입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| from langchain_community.retrievers import BM25Retriever
from langchain_core.documents import Document
# Document 객체 변환 (LangChain 형식)
docs_lc = [
Document(page_content=d, metadata={"index": i})
for i, d in enumerate(documents)
]
# BM25 Retriever 생성
bm25_retriever = BM25Retriever.from_documents(docs_lc)
bm25_retriever.k = 3 # 반환할 문서 수
# 검색
results = bm25_retriever.invoke('Python 프로그래밍')
for doc in results:
print(doc.page_content)
|
rank_bm25 패키지 필수: pip install rank_bm25
8. FAISS 벡터 스토어
Facebook AI가 개발한 고성능 벡터 유사도 검색 라이브러리입니다.
1
2
3
4
5
6
7
8
9
10
11
12
| from langchain_community.vectorstores import FAISS
# FAISS 벡터 스토어 생성 (임베딩 + 문서를 한번에)
vectorstore = FAISS.from_documents(docs_lc, embeddings)
# Retriever로 변환
vector_retriever = vectorstore.as_retriever(search_kwargs={'k': 3})
# 검색
results = vector_retriever.invoke('딥러닝 모델 구조')
for doc in results:
print(doc.page_content)
|
1
2
3
4
| 특징:
- 대규모 문서에서 빠른 근사 최근접 이웃 검색(ANN)
- 메모리와 속도 최적화
- 로컬 저장/로드 가능 (vectorstore.save_local / FAISS.load_local)
|
9. Ensemble Retriever (하이브리드 검색)
BM25(키워드)와 FAISS(벡터)를 가중치로 결합하여 두 방식의 장점을 모두 취합니다.
1
2
3
4
5
6
7
8
9
| from langchain_classic.retrievers import EnsembleRetriever
# 가중치 0.5:0.5으로 결합
ensemble = EnsembleRetriever(
retrievers=[vector_retriever, bm25_retriever],
weights=[0.5, 0.5], # [벡터 가중치, BM25 가중치]
)
results = ensemble.invoke("Python 데이터 과학")
|
가중치 실험
1
2
3
4
5
6
7
8
| for w_vec, w_bm25 in [(0.2, 0.8), (0.5, 0.5), (0.8, 0.2)]:
ensemble = EnsembleRetriever(
retrievers=[vector_retriever, bm25_retriever],
weights=[w_vec, w_bm25],
)
results = ensemble.invoke("Python 데이터 과학")
top_idx = results[0].metadata['index']
print(f"BM25={w_bm25}, Vector={w_vec} -> Top-1 문서 인덱스: {top_idx}")
|
1
2
3
| 가중치 조정 방향:
- BM25 가중치 ↑: 정확한 키워드 매칭 중시 (전문 용어, 코드명)
- Vector 가중치 ↑: 의미적 유사성 중시 (자연어 질문, 개념 검색)
|
하이브리드 검색이 필요한 이유
1
2
3
4
5
6
7
| 벡터 검색만: "Python 프로그래밍" 검색 시 "코딩 언어" 같은 의미적 유사 문서 찾음
→ 그러나 "Python"이라는 단어가 없어도 높은 점수 가능
BM25만: "Python"이라는 단어가 정확히 있어야 높은 점수
→ "파이썬"으로 표기된 문서는 못 찾음
하이브리드: 두 방식의 결과를 결합 → 정확성 + 의미적 이해 모두 확보
|
10. 자주 나오는 실수 / 주의사항
rank_bm25 미설치: BM25Retriever import 시 ImportError 발생 → pip install rank_bm25embed_documents() 반환값은 list: .shape 바로 호출 불가 → np.array(doc_embeddings).shapeEnsembleRetriever의 weights 합은 1.0: 합이 1이 아니면 결과 비율 왜곡- BM25는 한국어 토크나이저 기본 없음:
.split()으로 공백 기준 분리 → 형태소 분석기(KoNLPy 등) 연결 시 성능 향상 - FAISS는 인덱스 저장 안 하면 재시작 시 재생성 필요:
vectorstore.save_local("faiss_index")로 저장 권장
[보충] 검색 기술 비교 및 선택 가이드
기술별 비교표
| 검색 방식 | 의미 이해 | 속도 | 구현 난이도 | 주요 사용 사례 |
|---|
| 키워드 | X | 매우 빠름 | 쉬움 | 정확한 용어 검색 |
| TF-IDF | X | 빠름 | 보통 | 전통적 문서 검색 |
| BM25 | X | 빠름 | 보통 | 실무 문서 검색 (표준) |
| 벡터 (FAISS) | O | 느림 | 보통 | 의미/개념 기반 검색 |
| 하이브리드 (Ensemble) | O | 중간 | 복잡 | 실전 RAG 시스템 |
검색 기술 발전 흐름
1
2
3
4
5
6
7
8
9
| 키워드 검색
↓ 단어 빈도 문제 개선
TF-IDF (단어 중요도 반영)
↓ 문서 길이 편향 개선
BM25 (길이 정규화 + 포화 효과)
↓ 의미 이해 추가
벡터 검색 (임베딩 기반 코사인 유사도)
↓ 두 방식 결합
하이브리드 검색 (Ensemble: 정확성 + 의미)
|
실무에서 선택 기준
1
2
3
| 단순 키워드 검색으로 충분한 경우: 코드명, 제품명, 고유 용어 검색
의미 기반이 중요한 경우: 자연어 질문, 개념 검색, 다국어 환경
실전 RAG 시스템: 하이브리드 (BM25 + FAISS Ensemble) 권장
|
끝