# 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