query를 던졌을 때 이에 맞는 passage(문서)를 찾는 것.
구조화된 문서에서 찾을 수도 있지만, 일반적인 웹상에서 굴러다니는 모든 문서가 그 대상에 해당될 수 있다.
ODQA 즉 Open-Domain Question Answering에 활용할 수 있기 때문이다.
지금까지 MRC에서 배운 내용과 Retrieval의 기능을 합치면 총 두 단계로 이루어진 ODQA가 가능하다.
실제로 어떻게 Passage Retrieval에 접근해야 할까?
일반적으로 Query와 Passage에 대해서 각각을 같은 Vector Space에 Embedding한다.
다만, 이때 Passage에 해당하는 '문서'의 경우 질문이 들어올 때마다 매번 Embedding을 새로 진행하면 매우 비효율적인 바, 미리 Embedding 해놓고 있고
새롭게 Query가 들어올 때 마다 해당 Query를 Passage가 Embedding 된 Vector Space로 같이 Embedding해준다.
그렇게 임베딩된 Query와 임베딩된 각각의 Passage에 대해서 Similarity Score를 측정한다.
일반적으로 많이 쓰는 Similaryity Score로는 Nearest Neighbor, innerproduct 등이 있다.
이렇게 유사도 점수를 구해서 가장 높은 Rank에 위치한 문서(Passage)의 순서대로 내보내주는 방식을 채택하게 된다.
결국은 Passage를 Vector Space로 맵핑하려고 하는 건데, 이 Vector Space라는 것은 고차원의 숫자로 이루어진 포인트들이 모여있는 추상적 공간이며, 이러한 공간에 있는 Passage와 Query는 서로 간의 유사도 등의 알고리즘을 활용할 수 있는 계산 가능한 상태가 됨.
그래서 중요한건 결국은 어떤 방식의 Embedding으로 Vector Space에 Query와 Passage를 끌고올 것인가 라고 할 수있다.
Sparse Embedding이란?
고차원 벡터로, 대부분의 값이 0인 희소 벡터를 의미한다. 주로 단어의 출현 여부나 빈도를 나타내는데 사용된다.
단, Bag-of-Words(BOW) 모델에서는 각 단어의 출현 여부를 1로 표시하므로, 짧은 문서에서는 1의 비율이 많아질 수 있다.
따라서 Sparse Embedding의 핵심은 대부분의 값이 0이라는 것이지만, 이는 대규모 데이터셋이나 긴 문서에서 더 두드러질뿐 Sparse Embedding 자체가 항상 0으로만 이루어진 것은 아니다.
예시 : TF-IDF 벡터
문장: "The cat sat on the mat"
[0, 0, 0, ..., 0.5, 0, ..., 0.3, 0, ..., 0.5, 0, ...]
^ ^ ^
"cat" "sat" "mat"
코드 예시
from sklearn.feature_extraction.text import TfidfVectorizer
corpus = [
"The cat sat on the mat",
"The dog ran in the park"
]
vectorizer = TfidfVectorizer()
X = vectorizer.fit_transform(corpus)
print(X.toarray())
텍스트 데이터를 수치화하는 간단한 방법을 말한다.
각 문서를 단어의 출현 빈도로 표현한다.
코드 예시
from sklearn.feature_extraction.text import CountVectorizer
# 샘플 문서들
documents = [
'it was the best of times',
'it was the worst of times'
]
# Bag-of-Words 모델 생성
vectorizer = CountVectorizer()
X = vectorizer.fit_transform(documents)
# 결과를 배열로 변환
bow_array = X.toarray()
# 키워드 리스트 확보
feature_names = vectorizer.get_feature_names_out()
# 출력
print("BoW Array:")
print(bow_array)
print("\nFeature Names:")
print(feature_names)
출력결과
Bow Array :
[[1 1 1 1 1 0]
[0 1 1 1 1 1]]
Feature Names:
['best', 'it', 'of', 'the', 'times', 'was', 'worst']
정리
Sparse Embedding의 경우에 단어장이 커질수록, N-gram의 n이 커질수록 그 크기가 매우 크게 증가한다.
Term overlap 즉, 단어가 들어가 있는지 확인해야할 때 매우 유용하지만, 비슷한 의미를 가졌지만 다른 단어인 경우에는 비교가 불가하다. 단어의 유의성에는 취약하다!
Dense Embedding의 경우 저차원 벡터로, 대부분의 값이 0이 아닌 밀집 벡터이다.
단어나 문장의 의미를 압축해서 표현한다.
예시 : Word2Vec 벡터(300차원)
단어 : "cat"
[0.2, -0.1, 0.5, ..., 0.3, -0.4, 0.1]
코드 예시
from gensim.models import Word2Vec
sentences = [["cat", "sat", "on", "the", "mat"],
["dog", "ran", "in", "the", "park"]]
model = Word2Vec(sentences, vector_size=100, window=5, min_count=1, workers=4)
print(model.wv["cat"])
Sparse Embedding의 경우 정확한 키워드 매칭이 필요한 경우에 유용하며, Dense Embedding은 의미적 유사성을 고려한 검색이나 분류 작업에 적합하다.
최근에는 두 방식을 결합하여 사용하는 하이브리드 접근법도 많이 사용되고 있다.
| 특성 | Sparse Embedding | Dense Embedding |
|---|---|---|
| 차원 | 고차원 (수만) | 저차원 (100~1000) |
| 값 분포 | 대부분 0 | 대부분 0이 아님 |
| 장점 | 정확한 단어 매칭, 메모리 효율 | 의미적 유사성 포착, 차원 축소 |
| 단점 | 의미적 유사성 포착 어려움 | 정확한 단어 매칭 어려움 |
| 대표 기법 | TF-IDF, BM25 | Word2Vec, BERT |
Hybrid Search란 Sparse Embedding과 Dense Embedding의 장점을 결합하여 검색 성능을 향상시키는 방법을 의미한다.
!pip install pinecone-client
import pinecone
import os
# Pinecone 초기화
pinecone.init(api_key=os.environ["PINECONE_API_KEY"], environment="us-west1-gcp")
index_name = "hybrid-search-example"
if index_name not in pinecone.list_indexes():
pinecone.create_index(
index_name,
dimension=384, # OpenAI ada-002 모델의 차원
metric="cosine",
pod_type="p1"
)
# 인덱스 연결
index = pinecone.Index(index_name)
from sentence_transformers import SentenceTransformer
from sklearn.feature_extraction.text import TfidfVectorizer
# 샘플 데이터
texts = [
"The quick brown fox jumps over the lazy dog",
"A fast orange fox leaps above a sleepy canine",
"The lazy dog sleeps all day long"
]
# Dense 임베딩
model = SentenceTransformer('all-MiniLM-L6-v2')
dense_vectors = model.encode(texts)
# Sparse 임베딩
vectorizer = TfidfVectorizer()
sparse_vectors = vectorizer.fit_transform(texts)
for i, (dense, sparse) in enumerate(zip(dense_vectors, sparse_vectors)):
sparse_dict = {str(idx): value for idx, value in zip(sparse.indices, sparse.data)}
index.upsert(
vectors=[
{
"id": f"vec{i}",
"values": dense.tolist(),
"sparse_values": sparse_dict,
"metadata": {"text": texts[i]}
}
]
)
# 검색 쿼리
query = "fast fox"
# Dense 쿼리 벡터
dense_query = model.encode([query])[0].tolist()
# Sparse 쿼리 벡터
sparse_query = vectorizer.transform([query])
sparse_query_dict = {str(idx): value for idx, value in zip(sparse_query.indices, sparse_query.data)}
# Hybrid Search 실행
search_results = index.query(
vector=dense_query,
sparse_vector=sparse_query_dict,
top_k=2,
include_metadata=True
)
# 결과 출력
for result in search_results['matches']:
print(f"ID: {result['id']}")
print(f"Score: {result['score']}")
print(f"Text: {result['metadata']['text']}")
print("---")
예상 출력
ID: vec1
Score: 0.9876543
Text: A fast orange fox leaps above a sleepy canine
---
ID: vec0
Score: 0.8765432
Text: The quick brown fox jumps over the lazy dog
---
문서에서 단어의 중요성을 평가하는 통계적 방법이다. 각 단어의 빈도와 문서 내에서의 희소성을 결합하여 가중치를 계산한다.
IDF
단어가 전체 문서에서 얼마나 흔하지 않은지를 나타낸다.
( N )은 전체 문서의 수
( df(t) )는 단어 ( t )가 등장한 문서의 수
TF-IDF
둘을 곱하여 특정 단어의 중요성을 평가한다.
TF(t,d) X IDF(t,D)
코드예시
from sklearn.feature_extraction.text import TfidfVectorizer
# 샘플 문서들
documents = [
'it was the best of times',
'it was the worst of times'
]
# TF-IDF 벡터화
vectorizer = TfidfVectorizer()
tfidf_matrix = vectorizer.fit_transform(documents)
# 결과를 배열로 변환
tfidf_array = tfidf_matrix.toarray()
# 키워드 리스트 확보
feature_names = vectorizer.get_feature_names_out()
# 출력
print("TF-IDF Array:")
print(tfidf_array)
print("\nFeature Names:")
print(feature_names)
실행 결과
TF-IDF Array:
[[0. 0.4472136 0. 0.4472136 0.4472136 0.4472136]
[0.4472136 0.4472136 0.4472136 0. 0. 0.4472136]]
Feature Names:
['best', 'it', 'of', 'the', 'times', 'was', 'worst']
TF-IDF와 코사인 유사도를 이용하여 Query와 가장 유사한 문서를 찾는 예시코드
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
# 샘플 문서 집합
documents = [
"The quick brown fox jumps over the lazy dog",
"A fast brown dog outpaces a quick fox",
"The lazy cat sleeps all day",
"A brown fox is quick and cunning"
]
# 쿼리 문장
query = "quick brown fox"
# TF-IDF 벡터라이저 초기화
vectorizer = TfidfVectorizer()
# 문서 집합에 대해 TF-IDF 행렬 생성
tfidf_matrix = vectorizer.fit_transform(documents)
# 쿼리에 대해 TF-IDF 벡터 생성
query_vector = vectorizer.transform([query])
# 코사인 유사도 계산
cosine_similarities = cosine_similarity(query_vector, tfidf_matrix).flatten()
# 유사도가 높은 순서대로 문서 인덱스 정렬
sorted_indices = np.argsort(cosine_similarities)[::-1]
# 결과 출력
print("Query:", query)
print("\nRanked documents by similarity:")
for idx in sorted_indices:
print(f"Document {idx}: {documents[idx]}")
print(f"Similarity: {cosine_similarities[idx]:.4f}")
print()
BM25는 정보 검색 분야에서 널리 사용되는 랭킹 알고리즘이며, TF-IDF와 마찬가지로 Sparse Embedding 방법이다.
document와 query 간의 유사도를 계산하는데 사용되며, BM25를 이용하여 문서를 파싱하고 유사도를 계산할 수 있다.
계산식
는 문서 D에서 쿼리 단어 의 빈도수
는 문서 D의 길이
은 평균 문서 길이
과 는 조정 가능한 매개변수 (일반적으로 )
from rank_bm25 import BM25Okapi
import numpy as np
# 샘플 문서 집합
corpus = [
"The quick brown fox jumps over the lazy dog",
"A fast brown dog outpaces a quick fox",
"The lazy cat sleeps all day",
"A brown fox is quick and cunning"
]
# 문서 토큰화
tokenized_corpus = [doc.lower().split() for doc in corpus]
# BM25 모델 생성
bm25 = BM25Okapi(tokenized_corpus)
# 쿼리 설정
query = "quick brown fox"
tokenized_query = query.lower().split()
# 문서 스코어 계산
doc_scores = bm25.get_scores(tokenized_query)
# 상위 N개 문서 가져오기
top_n = 2
top_docs = bm25.get_top_n(tokenized_query, corpus, n=top_n)
print("문서 스코어:", doc_scores)
print(f"\n상위 {top_n}개 문서:")
for doc in top_docs:
print(doc)