chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
# Copyright 2025-present the zvec project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal, Optional
|
||||
|
||||
from ..common.constants import TEXT, SparseVectorType
|
||||
from ..tool import require_module
|
||||
from .embedding_function import SparseEmbeddingFunction
|
||||
|
||||
|
||||
class BM25EmbeddingFunction(SparseEmbeddingFunction[TEXT]):
|
||||
"""BM25-based sparse embedding function using DashText SDK.
|
||||
|
||||
This class provides text-to-sparse-vector embedding capabilities using
|
||||
the DashText library with BM25 algorithm. BM25 (Best Matching 25) is a
|
||||
probabilistic retrieval function used for lexical search and document
|
||||
ranking based on term frequency and inverse document frequency.
|
||||
|
||||
BM25 generates sparse vectors where each dimension corresponds to a term in
|
||||
the vocabulary, and the value represents the BM25 score for that term. It's
|
||||
particularly effective for:
|
||||
|
||||
- Lexical search and keyword matching
|
||||
- Document ranking and information retrieval
|
||||
- Combining with dense embeddings for hybrid search
|
||||
- Traditional IR tasks where exact term matching is important
|
||||
|
||||
This implementation uses DashText's SparseVectorEncoder, which provides
|
||||
efficient BM25 computation for Chinese and English text using either a
|
||||
built-in encoder or custom corpus training.
|
||||
|
||||
Args:
|
||||
corpus (Optional[list[str]], optional): List of documents to train the
|
||||
BM25 encoder. If provided, creates a custom encoder trained on this
|
||||
corpus for better domain-specific accuracy. If ``None``, uses the
|
||||
built-in encoder. Defaults to ``None``.
|
||||
encoding_type (Literal["query", "document"], optional): Encoding mode
|
||||
for text processing. Use ``"query"`` for search queries (default) and
|
||||
``"document"`` for document indexing. This distinction optimizes the
|
||||
BM25 scoring for asymmetric retrieval tasks. Defaults to ``"query"``.
|
||||
language (Literal["zh", "en"], optional): Language for built-in encoder.
|
||||
Only used when corpus is None. ``"zh"`` for Chinese (trained on Chinese
|
||||
Wikipedia), ``"en"`` for English. Defaults to ``"zh"``.
|
||||
b (float, optional): Document length normalization parameter for BM25.
|
||||
Range [0, 1]. 0 means no normalization, 1 means full normalization.
|
||||
Only used with custom corpus. Defaults to ``0.75``.
|
||||
k1 (float, optional): Term frequency saturation parameter for BM25.
|
||||
Higher values give more weight to term frequency. Only used with
|
||||
custom corpus. Defaults to ``1.2``.
|
||||
**kwargs: Additional parameters for DashText encoder customization.
|
||||
|
||||
Attributes:
|
||||
corpus_size (int): Number of documents in the training corpus (0 if using built-in encoder).
|
||||
encoding_type (str): The encoding type being used ("query" or "document").
|
||||
language (str): The language of the built-in encoder ("zh" or "en").
|
||||
|
||||
Raises:
|
||||
ValueError: If corpus is provided but empty or contains non-string elements.
|
||||
TypeError: If input to ``embed()`` is not a string.
|
||||
RuntimeError: If DashText encoder initialization or training fails.
|
||||
|
||||
Note:
|
||||
- Requires Python 3.10, 3.11, or 3.12
|
||||
- Requires the ``dashtext`` package: ``pip install dashtext``
|
||||
- Two encoder options available:
|
||||
|
||||
1. **Built-in encoder** (no corpus needed): Pre-trained models for
|
||||
Chinese (zh) and English (en), good generalization, works out-of-the-box
|
||||
2. **Custom encoder** (corpus required): Better accuracy for domain-specific
|
||||
terminology, requires training on your full corpus with BM25 parameters
|
||||
|
||||
- Encoding types:
|
||||
|
||||
* ``encoding_type="query"``: Optimized for search queries (shorter text)
|
||||
* ``encoding_type="document"``: Optimized for document indexing (longer text)
|
||||
|
||||
- BM25 parameters (b, k1) only apply to custom encoder training
|
||||
- Output is sorted by indices (vocabulary term IDs) for consistency
|
||||
- Results are cached (LRU cache, maxsize=10) to reduce computation
|
||||
- No API key or network connectivity required (local computation)
|
||||
|
||||
Examples:
|
||||
>>> # Option 1: Using built-in encoder for Chinese (no corpus needed)
|
||||
>>> from zvec.extension import BM25EmbeddingFunction
|
||||
>>>
|
||||
>>> # For query encoding (Chinese)
|
||||
>>> bm25_query_zh = BM25EmbeddingFunction(language="zh", encoding_type="query")
|
||||
>>> query_vec = bm25_query_zh.embed("什么是机器学习")
|
||||
>>> isinstance(query_vec, dict)
|
||||
True
|
||||
>>> # query_vec: {1169440797: 0.29, 2045788977: 0.70, ...}
|
||||
|
||||
>>> # For document encoding (Chinese)
|
||||
>>> bm25_doc_zh = BM25EmbeddingFunction(language="zh", encoding_type="document")
|
||||
>>> doc_vec = bm25_doc_zh.embed("机器学习是人工智能的一个重要分支...")
|
||||
>>> isinstance(doc_vec, dict)
|
||||
True
|
||||
|
||||
>>> # Using built-in encoder for English
|
||||
>>> bm25_query_en = BM25EmbeddingFunction(language="en", encoding_type="query")
|
||||
>>> query_vec_en = bm25_query_en.embed("what is vector search service")
|
||||
>>> isinstance(query_vec_en, dict)
|
||||
True
|
||||
|
||||
>>> # Option 2: Using custom corpus for domain-specific accuracy
|
||||
>>> corpus = [
|
||||
... "机器学习是人工智能的一个重要分支",
|
||||
... "深度学习使用多层神经网络进行特征提取",
|
||||
... "自然语言处理技术用于理解和生成人类语言"
|
||||
... ]
|
||||
>>> bm25_custom = BM25EmbeddingFunction(
|
||||
... corpus=corpus,
|
||||
... encoding_type="query",
|
||||
... b=0.75,
|
||||
... k1=1.2
|
||||
... )
|
||||
>>> custom_vec = bm25_custom.embed("机器学习算法")
|
||||
>>> isinstance(custom_vec, dict)
|
||||
True
|
||||
|
||||
>>> # Hybrid search: combining with dense embeddings
|
||||
>>> from zvec.extension import DefaultLocalDenseEmbedding
|
||||
>>> dense_emb = DefaultLocalDenseEmbedding()
|
||||
>>> bm25_emb = BM25EmbeddingFunction(language="zh", encoding_type="query")
|
||||
>>>
|
||||
>>> query = "machine learning algorithms"
|
||||
>>> dense_vec = dense_emb.embed(query) # Semantic similarity
|
||||
>>> sparse_vec = bm25_emb.embed(query) # Lexical matching
|
||||
>>> # Combine scores for hybrid retrieval
|
||||
|
||||
>>> # Callable interface
|
||||
>>> sparse_vec = bm25_query_zh("information retrieval")
|
||||
>>> isinstance(sparse_vec, dict)
|
||||
True
|
||||
|
||||
>>> # Error handling
|
||||
>>> try:
|
||||
... bm25_query_zh.embed("") # Empty query
|
||||
... except ValueError as e:
|
||||
... print(f"Error: {e}")
|
||||
Error: Input text cannot be empty or whitespace only
|
||||
|
||||
See Also:
|
||||
- ``SparseEmbeddingFunction``: Base class for sparse embeddings
|
||||
- ``DefaultLocalSparseEmbedding``: SPLADE-based sparse embedding
|
||||
- ``QwenSparseEmbedding``: API-based sparse embedding using Qwen
|
||||
- ``DefaultLocalDenseEmbedding``: Dense embedding for semantic search
|
||||
|
||||
References:
|
||||
- DashText Documentation: https://help.aliyun.com/zh/document_detail/2546039.html
|
||||
- DashText PyPI: https://pypi.org/project/dashtext/
|
||||
- BM25 Algorithm: Robertson & Zaragoza (2009)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
corpus: Optional[list[str]] = None,
|
||||
encoding_type: Literal["query", "document"] = "query",
|
||||
language: Literal["zh", "en"] = "zh",
|
||||
b: float = 0.75,
|
||||
k1: float = 1.2,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the BM25 embedding function.
|
||||
|
||||
Args:
|
||||
corpus (Optional[list[str]]): Optional corpus for training custom encoder.
|
||||
If None, uses built-in encoder. Defaults to None.
|
||||
encoding_type (Literal["query", "document"]): Text encoding mode.
|
||||
Use "query" for search queries, "document" for indexing.
|
||||
Defaults to "query".
|
||||
language (Literal["zh", "en"]): Language for built-in encoder.
|
||||
"zh" for Chinese, "en" for English. Defaults to "zh".
|
||||
b (float): Document length normalization for BM25 [0, 1].
|
||||
Only used with custom corpus. Defaults to 0.75.
|
||||
k1 (float): Term frequency saturation for BM25.
|
||||
Only used with custom corpus. Defaults to 1.2.
|
||||
**kwargs: Additional DashText encoder parameters.
|
||||
|
||||
Raises:
|
||||
ValueError: If corpus is provided but empty or invalid.
|
||||
ImportError: If dashtext package is not installed.
|
||||
RuntimeError: If encoder initialization or training fails.
|
||||
"""
|
||||
# Validate corpus if provided
|
||||
if corpus is not None:
|
||||
if not corpus or not isinstance(corpus, list):
|
||||
raise ValueError("Corpus must be a non-empty list of strings")
|
||||
|
||||
if not all(isinstance(doc, str) for doc in corpus):
|
||||
raise ValueError("All corpus documents must be strings")
|
||||
|
||||
# Import dashtext
|
||||
self._dashtext = require_module("dashtext")
|
||||
|
||||
self._corpus = corpus
|
||||
self._encoding_type = encoding_type
|
||||
self._language = language
|
||||
self._b = b
|
||||
self._k1 = k1
|
||||
self._extra_params = kwargs
|
||||
|
||||
# Initialize the BM25 encoder
|
||||
self._build_encoder()
|
||||
|
||||
def _build_encoder(self):
|
||||
"""Build the BM25 sparse vector encoder.
|
||||
|
||||
Creates either a built-in encoder (pre-trained) or a custom encoder
|
||||
trained on the provided corpus.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If encoder initialization or training fails.
|
||||
ImportError: If dashtext package is not installed.
|
||||
"""
|
||||
try:
|
||||
if self._corpus is None:
|
||||
# Use built-in encoder (pre-trained on Wikipedia)
|
||||
# language: 'zh' for Chinese, 'en' for English
|
||||
self._encoder = self._dashtext.SparseVectorEncoder.default(
|
||||
name=self._language
|
||||
)
|
||||
else:
|
||||
# Create custom encoder with BM25 parameters
|
||||
self._encoder = self._dashtext.SparseVectorEncoder(
|
||||
b=self._b, k1=self._k1, **self._extra_params
|
||||
)
|
||||
|
||||
# Train encoder with the corpus
|
||||
self._encoder.train(self._corpus)
|
||||
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"dashtext package is required for BM25EmbeddingFunction. "
|
||||
"Install it with: pip install dashtext"
|
||||
) from e
|
||||
except Exception as e:
|
||||
if isinstance(e, (ValueError, RuntimeError)):
|
||||
raise
|
||||
raise RuntimeError(f"Failed to build BM25 encoder: {e!s}") from e
|
||||
|
||||
@property
|
||||
def corpus_size(self) -> int:
|
||||
"""int: Number of documents in the training corpus (0 if using built-in encoder)."""
|
||||
return len(self._corpus) if self._corpus is not None else 0
|
||||
|
||||
@property
|
||||
def encoding_type(self) -> str:
|
||||
"""str: The encoding type being used ("query" or "document")."""
|
||||
return self._encoding_type
|
||||
|
||||
@property
|
||||
def language(self) -> str:
|
||||
"""str: The language of the built-in encoder ("zh" or "en")."""
|
||||
return self._language
|
||||
|
||||
@property
|
||||
def extra_params(self) -> dict:
|
||||
"""dict: Extra parameters for DashText encoder customization."""
|
||||
return self._extra_params
|
||||
|
||||
def __call__(self, input: TEXT) -> SparseVectorType:
|
||||
"""Make the embedding function callable.
|
||||
|
||||
Args:
|
||||
input (TEXT): Input text to embed.
|
||||
|
||||
Returns:
|
||||
SparseVectorType: Sparse vector as dictionary.
|
||||
"""
|
||||
return self.embed(input)
|
||||
|
||||
@lru_cache(maxsize=10)
|
||||
def embed(self, input: TEXT) -> SparseVectorType:
|
||||
"""Generate BM25 sparse embedding for the input text.
|
||||
|
||||
This method computes BM25 scores for the input text using DashText's
|
||||
SparseVectorEncoder. The encoding behavior depends on the encoding_type:
|
||||
|
||||
- ``encoding_type="query"``: Uses ``encode_queries()`` for search queries
|
||||
- ``encoding_type="document"``: Uses ``encode_documents()`` for documents
|
||||
|
||||
The result is a sparse vector where keys are term indices in the
|
||||
vocabulary and values are BM25 scores.
|
||||
|
||||
Args:
|
||||
input (TEXT): Input text string to embed. Must be non-empty after
|
||||
stripping whitespace.
|
||||
|
||||
Returns:
|
||||
SparseVectorType: A dictionary mapping vocabulary term index to BM25 score.
|
||||
Only non-zero scores are included. The dictionary is sorted by indices
|
||||
(keys) in ascending order for consistent output.
|
||||
Example: ``{1169440797: 0.29, 2045788977: 0.70, ...}``
|
||||
|
||||
Raises:
|
||||
TypeError: If ``input`` is not a string.
|
||||
ValueError: If input is empty or whitespace-only.
|
||||
RuntimeError: If BM25 encoding fails.
|
||||
|
||||
Examples:
|
||||
>>> bm25 = BM25EmbeddingFunction(language="zh", encoding_type="query")
|
||||
>>> sparse_vec = bm25.embed("query text")
|
||||
>>> isinstance(sparse_vec, dict)
|
||||
True
|
||||
>>> all(isinstance(k, int) and isinstance(v, float) for k, v in sparse_vec.items())
|
||||
True
|
||||
|
||||
>>> # Verify sorted output
|
||||
>>> keys = list(sparse_vec.keys())
|
||||
>>> keys == sorted(keys)
|
||||
True
|
||||
|
||||
>>> # Error: empty input
|
||||
>>> bm25.embed(" ")
|
||||
ValueError: Input text cannot be empty or whitespace only
|
||||
|
||||
>>> # Error: non-string input
|
||||
>>> bm25.embed(123)
|
||||
TypeError: Expected 'input' to be str, got int
|
||||
|
||||
Note:
|
||||
- BM25 scores are relative to the vocabulary statistics
|
||||
- Output dictionary is always sorted by indices for consistency
|
||||
- Terms not in the vocabulary will have zero scores (not included)
|
||||
- This method is cached (maxsize=10) for performance
|
||||
- DashText automatically handles Chinese/English text segmentation
|
||||
"""
|
||||
if not isinstance(input, str):
|
||||
raise TypeError(f"Expected 'input' to be str, got {type(input).__name__}")
|
||||
|
||||
input = input.strip()
|
||||
if not input:
|
||||
raise ValueError("Input text cannot be empty or whitespace only")
|
||||
|
||||
try:
|
||||
# Encode based on encoding_type
|
||||
if self._encoding_type == "query":
|
||||
sparse_vector = self._encoder.encode_queries(input)
|
||||
else: # encoding_type == "document"
|
||||
sparse_vector = self._encoder.encode_documents(input)
|
||||
|
||||
# DashText returns dict with int/long keys and float values
|
||||
# Convert to standard format: {int: float}
|
||||
sparse_dict: dict[int, float] = {}
|
||||
for key, value in sparse_vector.items():
|
||||
try:
|
||||
idx = int(key)
|
||||
val = float(value)
|
||||
if val > 0:
|
||||
sparse_dict[idx] = val
|
||||
except (ValueError, TypeError):
|
||||
# Skip invalid entries
|
||||
continue
|
||||
|
||||
# Sort by indices (keys) to ensure consistent ordering
|
||||
return dict(sorted(sparse_dict.items()))
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, (TypeError, ValueError)):
|
||||
raise
|
||||
raise RuntimeError(f"Failed to generate BM25 embedding: {e!s}") from e
|
||||
Reference in New Issue
Block a user