이 글은 What is a Sparse Vector? How to Achieve Vector-based Hybrid Search 를 보고 정리한 글입니다.


BM25 의 한계:

  • 단어 빈도에만 의존하고 단어의 의미나 문맥을 이해하지 못함.

 

 

Sparse Vector 의 정의:

  • Dense Vector 가 단어를 수백 수천개의 벡터 차원으로 나타내는 반면에 Sparse Vector 는 대부분의 값이 0이라서 0이 아닌 값들만 선별되서 벡터로 나타내짐. 주로 단어의 존재 유무와 해당 단어의 중요도값이 Sparse Vector 로 표현된다. (e.g Sparse: [{331: 0.5}, {14136: 0.7}] (20개의 키-값 쌍))
  • Sparse Vector 의 각 값은 특정 단어나 부분 단어인 토큰에(예: 'chocolate', 'icecream')에 매핑된다.
  • Sparse Vector 에서 단어의 중요도를 측정하는 방법은 여러가지가 있음. IDF 기반 or 신경망 모델 기반 (트랜스포머 모델을 써서 해당 문서 내에서 각 단어의 중요도를 측정하고 이를 행렬로 표현할 수 있음)

 

 

Sparse Vector 의 장점:

  • 신경망 모델을 활용하면 BM25의 한계를 극복할 수 있다. 그러면서도 정확한 단어와 구문 검색 능력은 유지할 수 있음.

 

 

Sparse Vector vs Dense Vector 비교:

  • Sparse Vector:
    • 각 차원이 단어나 부분 단어에 대응됨.
    • 각 차원이 특정 단어에 매핑되고, 중요도를 알 수 있으니까 랭킹을 해석하는 데에도 도움이 됨.
    • 텍스트 위주의 애플리케이션(예: 검색)에 특히 유용하다.
    • 희귀 키워드나 전문 용어가 많은 도메인에서 검색에 강함.
  • Dense Vector:
    • 수백, 수천개의 차원을 가진 벡터로 표현됨. 그래서 정보 밀도가 더 높음.
    • 수천개의 차원의 벡터로 복잡한 의미를 포착할 수 있음. 이를 기반으로 유사도 검색을 할 수 있다.
    • 다국어 작업에 효과적임.
    • RAG 와 일반적인 머신러닝 작업에 사용될 수 있음.

 

 

SPLADE(SParse Lexical AnD Expansion):

  • 신경망 모델을 이용해서 sparse 벡터 표현을 생성하는 방법임.
  • 단어 확장(expansion) 매커니즘을 이용해서 원본 텍스트에 없는 관련 단어도 포함시킬 수 있다.
  • 기존의 sparse 모델들보다 더 높은 성능을 냄. 다음과 같은 벤치마크를 보면 MRR 측면에서 SPLADE 의 변형들이 높은 성능을 내는 걸 볼 수 있음.

 

 

 

Creating a sparse vector:

Setting Up:

from transformers import AutoModelForMaskedLM, AutoTokenizer

model_id = "naver/splade-cocondenser-ensembledistil"

tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForMaskedLM.from_pretrained(model_id)

text = """Arthur Robert Ashe Jr. (July 10, 1943 – February 6, 1993) was an American professional tennis player. He won three Grand Slam titles in singles and two in doubles."""

 

 

Computing the sparse vector:

  • 이 코드는 SPLADE 모델을 사용하여 텍스트를 sparse 벡터로 변환하는 과정을 구현한 함수임.
def compute_vector(text):
    """
    Computes a vector from logits and attention mask using ReLU, log, and max operations.
    """
    # 입력 텍스트를 토큰화하여 PyTorch 텐서로 변환한다. 
    tokens = tokenizer(text, return_tensors="pt")

    # SPLADE 모델에 토큰을 입력하여 결과를 얻는다. 
    output = model(**tokens)

    # 모델 출력에서 로짓과 어텐션 마스크를 추출한다. 
    # 로짓은 모델의 최종 출력층에서 나오는 원시 예측 값임. 각 단어의 중요도나 관련성을 나타내는 원시 점수로 사용됨. 
    # 어텐션 마스크는 시퀀스 내에서 실제 토큰과 패딩 토큰을 구분하는 이진 마스크임. 모델이 패딩 토큰을 무시하고 실제 입력 토큰에만 집중하도록 함. 
    logits, attention_mask = output.logits, tokens.attention_mask 

    # 로짓에 ReLU를 적용하고 로그를 취한다. 이는 값을 양수로 만들고 스케일을 조정하는거임. 
    relu_log = torch.log(1 + torch.relu(logits))

    # 로그 값에 어텐션 마스크를 곱하여 패딩 토큰의 영향을 제거함. 
    weighted_log = relu_log * attention_mask.unsqueeze(-1)

    # 각 차원에 대해 최대값을 추출한다. 이는 각 단어의 가장 강한 활성화를 선택한다. 
    max_val, _ = torch.max(weighted_log, dim=1)

    # 최종적으로 1차원 벡터를 생성한다. 
    vec = max_val.squeeze()

    return vec, tokens

# Sparse Vector 생성 
vec, tokens = compute_vector(text)

# 벡터 크기 출력 
print(vec.shape)

 

 

Term expansion and weights:

  • 이 코드는 SPLADE 모델의 출력 벡터를 해석 가능한 형태로 변환하는 함수임.
def extract_and_map_sparse_vector(vector, tokenizer):
    """
    Extracts non-zero elements from a given vector and maps these elements to their human-readable tokens using a tokenizer. The function creates and returns a sorted dictionary where keys are the tokens corresponding to non-zero elements in the vector, and values are the weights of these elements, sorted in descending order of weights.

    This function is useful in NLP tasks where you need to understand the significance of different tokens based on a model's output vector. It first identifies non-zero values in the vector, maps them to tokens, and sorts them by weight for better interpretability.

    Args:
    vector (torch.Tensor): A PyTorch tensor from which to extract non-zero elements.
    tokenizer: The tokenizer used for tokenization in the model, providing the mapping from tokens to indices.

    Returns:
    dict: A sorted dictionary mapping human-readable tokens to their corresponding non-zero weights.
    """

    # Extract indices and values of non-zero elements in the vector
    # 비제로 요소를 추출. 그러니까 벡터에서 0이 아닌 요소의 인덱스와 값을 추출한다. 
    cols = vector.nonzero().squeeze().cpu().tolist()
    weights = vector[cols].cpu().tolist()


    # Map indices to tokens and create a dictionary
    # 인덱스를 토큰으로 매핑. 그러니까 토크나이저의 어휘를 사용하여 인덱스를 실제 토큰으로 매핑한다. 
    idx2token = {idx: token for token, idx in tokenizer.get_vocab().items()}

    # 토큰-가중치 딕셔너리 생성한다. 그러니까 각 토큰과 그에 해당하는 가중치를 연결하여 딕셔너리를 만드는거. 
    token_weight_dict = {
        idx2token[idx]: round(weight, 2) for idx, weight in zip(cols, weights)
    }

    # Sort the dictionary by weights in descending order
    # 가중치 기준 정렬한다. 
    sorted_token_weight_dict = {
        k: v
        for k, v in sorted(
            token_weight_dict.items(), key=lambda item: item[1], reverse=True
        )
    }

    return sorted_token_weight_dict


# Usage example
sorted_tokens = extract_and_map_sparse_vector(vec, tokenizer)
sorted_tokens

 

 

sorted_token 출력 결과:

{
    "ashe": 2.95,
    "arthur": 2.61,
    "tennis": 2.22,
    "robert": 1.74,
    "jr": 1.55,
    "he": 1.39,
    "founder": 1.36,
    "doubles": 1.24,
    "won": 1.22,
    "slam": 1.22,
    "died": 1.19,
    "singles": 1.1,
    "was": 1.07,
    "player": 1.06,
    "titles": 0.99, 
    ...
}

 

 

SPLADE(SParse Lexical AnD Expansion) 모델의 작동 원리: 용어 확장 (Term Expansion)

  • Term Expansion 은 SPLADE 의 핵심 기능응로 "solar energy advantages" 쿼리 같은 걸 "renewable," "sustainable," "photovoltaic" 등으로 확장할 수 있다. 그러니까 문맥적으로 연관이 있지만 명시적으로 언급하지 않은 쿼리를 포함시키는거임.
  • 이렇게 용어 홪강 개념을 이용해서 다른 sparse 방법들과 달리 문맥적으로 관련된 용어들을 포함시킬 수 있고 이걸 검색에 사용할 수 있음.
  • 다음과 같이 Sparse Vector 는 메모리 사용량도 확실히 줄어듬.

 

 

SPLADE(SParse Lexical AnD Expansion)의 작동 원리: BERT 모델 활용.

  • SPLADE는 BERT와 같은 트랜스포머 모델을 기반으로 사용할 수 있음. 트랜스포머 모델의 출력 로짓인 dense probability distributions 을 입력으로 받아서 SPLADE 는 이걸 증류해서 중요한 던어를 선별하는거임.
  • 이런 중요한 단어는 다음과 같은 두 가지 특성을 기반으로 선별함:
    • Contextually relevant (문맥적 관련성): 문서를 잘 표현하는 단어에 더 높은 가중치를 부여함.
    • Discriminative across documents (문서 간 구별성): 다른 문서에는 없고 해당 문서에만 있는 단어에 더 높은 가중치를 부여함.
  • 이 과정은 정보 검색에 더 적합한 단어 표현을 만들어낼 수 있음.

 

 

SPLADE 의 해석가능성(interpretability):

  • Dense 벡터는 해석하기 어려운 반면에 SPLADE 의 Sparse 벡터는 중요도 추정(importance estimation)을 통해 문서가 쿼리와 관련 있는 이유를 파악할 수 있음.

 

 

다른 Sparse Vector 를 생성하는 방법:

  • TF-IDF
  • Sentence Transformers (avoid query expansion 방법임)

 

 

Hybrid Search 에서 Sparse Vector 사용하기:

  • Qdrant 에서는 Sparse Vector 사용을 위한 Index 를 지원한다. 이걸 사용하면 컬렉션 내에서 Dense Search 와 Sparse Search 모두 사용할 수 있음.

 

 

Practical implementation in Python:

  1. Setting Up Qdrant Client: Initially, we establish a connection with Qdrant using the QdrantClient. This setup is crucial for subsequent operations.
# Qdrant client setup
client = QdrantClient(":memory:")

# Define collection name
COLLECTION_NAME = "example_collection"

# Insert sparse vector into Qdrant collection
point_id = 1  # Assign a unique ID for the point

 

 

  1. Creating a Collection with Sparse Vector Support: In Qdrant, a collection is a container for your vectors. Here, we create a collection specifically designed to support sparse vectors. This is done using the recreate_collection method where we define the parameters for sparse vectors, such as setting the index configuration.
client.recreate_collection(
    collection_name=COLLECTION_NAME,
    vectors_config={},
    sparse_vectors_config={
        "text": models.SparseVectorParams(
            index=models.SparseIndexParams(
                on_disk=False,
            )
        )
    },
)

 

 

  1. Inserting Sparse Vectors: Once the collection is set up, we can insert sparse vectors into it. This involves defining the sparse vector with its indices and values, and then upserting this point into the collection.
client.upsert(
    collection_name=COLLECTION_NAME,
    points=[
        models.PointStruct(
            id=point_id,
            payload={},  # Add any additional payload if necessary
            vector={
                "text": models.SparseVector(
                    indices=indices.tolist(), values=values.tolist()
                )
            },
        )
    ],
)

 

 

  1. Querying with Sparse Vectors: To perform a search, we first prepare a query vector. This involves computing the vector from a query text and extracting its indices and values. We then use these details to construct a query against our collection.
# Preparing a query vector

query_text = "Who was Arthur Ashe?"
query_vec, query_tokens = compute_vector(query_text)
query_vec.shape

query_indices = query_vec.nonzero().numpy().flatten()
query_values = query_vec.detach().numpy()[indices]

 

 

  1. Retrieving and Interpreting Results: The search operation returns results that include the id of the matching document, its score, and other relevant details. The score is a crucial aspect, reflecting the similarity between the query and the documents in the collection.
# Searching for similar documents
result = client.search(
    collection_name=COLLECTION_NAME,
    query_vector=models.NamedSparseVector(
        name="text",
        vector=models.SparseVector(
            indices=query_indices,
            values=query_values,
        ),
    ),
    with_vectors=True,
)

result

 

 

검색 출력 결과.

ScoredPoint(
    id=1,
    version=0,
    score=3.4292831420898438,
    payload={},
    vector={
        "text": SparseVector(
            indices=[2001, 2002, 2010, 2018, 2032, ...],
            values=[
                1.0660614967346191,
                1.391068458557129,
                0.8903818726539612,
                0.2502821087837219,
                ...,
            ],
        )
    },
)

 

 

Hybrid search: combining sparse and dense vectors

client.recreate_collection(
    collection_name=COLLECTION_NAME,
    vectors_config={
        "text-dense": models.VectorParams(
            size=1536,  # OpenAI Embeddings
            distance=models.Distance.COSINE,
        )
    },
    sparse_vectors_config={
        "text-sparse": models.SparseVectorParams(
            index=models.SparseIndexParams(
                on_disk=False,
            )
        )
    },
)
query_text = "Who was Arthur Ashe?"

# Compute sparse and dense vectors
query_indices, query_values = compute_sparse_vector(query_text)
query_dense_vector = compute_dense_vector(query_text)


client.search_batch(
    collection_name=COLLECTION_NAME,
    requests=[
        models.SearchRequest(
            vector=models.NamedVector(
                name="text-dense",
                vector=query_dense_vector,
            ),
            limit=10,
        ),
        models.SearchRequest(
            vector=models.NamedSparseVector(
                name="text-sparse",
                vector=models.SparseVector(
                    indices=query_indices,
                    values=query_values,
                ),
            ),
            limit=10,
        ),
    ],
)

 

 

이렇게 Hybrid Search 로 각각 가져온 결과에 대해서 합치는 방법은 다음과 같음:

  • Mixing or fusion:
    • Reciprocal Ranked Fusion (RRF)
    • Relative Score Fusion (RSF)
    • Distribution-Based Score Fusion (DBSF)
  • Re-ranking:
    • Cross-Encoders
    • Cohere Rerank:

+ Recent posts