Skip to content

Commit ba931e8

Browse files
authored
feat: sparse vector index integration (#179)
## Summary - Add sparse index support in admin client, collection, configuration, and types - Extend BM25 sparse embedding function and unit/integration tests - Add `sparse_vector_index_example.py` with hybrid search and reranking demos ## Changes - **Client**: `admin_client`, `client_base`, `collection`, `configuration`, `types` updated for sparse vector index - **Embedding**: `bm25_sparse_embedding_function` improvements and tests - **Tests**: `test_sparse_vector_index`, `test_bm25_sparse_embedding_function` - **Example**: `examples/sparse_vector_index_example.py` (sparse-only + hybrid search with RRF/score/model reranking) Made with [Cursor](https://cursor.com) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * New Python example demonstrating sparse-vector and hybrid search with RRF, score-based, and optional model-based reranking (model rerank requires sentence-transformers). * **Bug Fixes & Improvements** * BM25 sparse embedding rewritten with configurable dimension and language, improved tokenization/scoring, and optional PyStemmer support. * Vector index configs auto-assign a default embedding function when omitted. * Unified "not provided" parameter semantics for clearer behavior. * **Removals** * Removed legacy has_sparse_vector_index property; use sparse_vector_index_config. * Consolidated query embedding into a single callable interface. * **Other** * Project version bumped to 1.2.0.dev1; CI/tests updated for bm25s. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 223d171 commit ba931e8

16 files changed

Lines changed: 2700 additions & 374 deletions

.github/workflows/ci.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,6 @@ jobs:
7676
with:
7777
python-version: ${{ matrix.python-version }}
7878

79-
- name: Install depencies for unit tests
80-
run: |
81-
uv run pip install openai sentence-transformers torch torchvision torchaudio boto3 litellm voyageai
82-
8379
- name: Run unit tests
8480
run: |
8581
if [ "${{ env.RUNNER_DEBUG }}" = "true" ]; then
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
from __future__ import annotations
2+
3+
import contextlib
4+
from typing import Any, Literal
5+
6+
from pyseekdb import Client, HNSWConfiguration, K, Schema, SparseVectorIndexConfig
7+
from pyseekdb.utils.embedding_functions import BM25SparseEmbeddingFunction
8+
9+
# 1. Initialize client (Embedded mode; creates seekdb.db in the current directory)
10+
client = Client()
11+
12+
# Clean up stale collections from previous runs
13+
for name in ["demo_sparse_collection", "hybrid_demo"]:
14+
with contextlib.suppress(Exception):
15+
client.delete_collection(name)
16+
17+
# 2. Define schema: BM25 sparse vector index using document content (K.DOCUMENT) as source
18+
sparse_config = SparseVectorIndexConfig(
19+
embedding_function=BM25SparseEmbeddingFunction(k=1.2, b=0.75),
20+
source_key=K.DOCUMENT,
21+
drop_ratio_search=0.2,
22+
)
23+
24+
schema = Schema(sparse_vector_index=sparse_config)
25+
26+
# 3. Create collection
27+
collection_name = "demo_sparse_collection"
28+
collection = client.create_collection(name=collection_name, schema=schema)
29+
30+
print(f"Collection '{collection_name}' created successfully.")
31+
32+
collection.add(
33+
ids=["1", "2", "3"],
34+
documents=[
35+
"Machine learning is fascinating.",
36+
"Python is a great programming language.",
37+
"Artificial Intelligence and machine learning are related.",
38+
],
39+
)
40+
41+
# 5. Run sparse vector search
42+
results = collection.query(
43+
query_texts=["machine learning"],
44+
query_key=K.SPARSE_EMBEDDING,
45+
n_results=2,
46+
)
47+
48+
print("Sparse Search Results:")
49+
for i, doc_id in enumerate(results["ids"][0]):
50+
print(f"Rank {i + 1}: ID={doc_id}, Distance={results['distances'][0][i]}, Document={results['documents'][0][i]}")
51+
52+
53+
# =====================================================================
54+
# Hybrid search with pluggable reranking strategies
55+
# =====================================================================
56+
57+
RerankMethod = Literal["rrf", "score", "model"]
58+
59+
60+
def _normalize_scores(distances: list[float], higher_is_better: bool) -> list[float]:
61+
"""Normalize a list of distance/score values to [0, 1]."""
62+
if not distances:
63+
return []
64+
lo, hi = min(distances), max(distances)
65+
if hi == lo:
66+
return [1.0] * len(distances)
67+
if higher_is_better:
68+
return [(d - lo) / (hi - lo) for d in distances]
69+
else:
70+
return [(hi - d) / (hi - lo) for d in distances]
71+
72+
73+
def _rerank_rrf(
74+
dense_ids: list[str],
75+
sparse_ids: list[str],
76+
dense_results: dict[str, Any],
77+
sparse_results: dict[str, Any],
78+
rrf_k: int,
79+
) -> dict[str, float]:
80+
"""Reciprocal Rank Fusion: merge by rank position, ignore score magnitude."""
81+
scores: dict[str, float] = {}
82+
for rank, doc_id in enumerate(dense_ids):
83+
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (rrf_k + rank + 1)
84+
for rank, doc_id in enumerate(sparse_ids):
85+
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (rrf_k + rank + 1)
86+
return scores
87+
88+
89+
def _rerank_score(
90+
dense_ids: list[str],
91+
sparse_ids: list[str],
92+
dense_results: dict[str, Any],
93+
sparse_results: dict[str, Any],
94+
alpha: float,
95+
) -> dict[str, float]:
96+
"""
97+
Score-based fusion: normalize both distance lists to [0, 1] and combine
98+
with ``alpha * dense + (1 - alpha) * sparse``.
99+
100+
Dense distances are L2/cosine (lower = better); sparse distances are
101+
inner-product (higher = better).
102+
"""
103+
dense_distances = dense_results["distances"][0] if dense_ids else []
104+
sparse_distances = sparse_results["distances"][0] if sparse_ids else []
105+
106+
dense_norm = _normalize_scores(dense_distances, higher_is_better=False)
107+
sparse_norm = _normalize_scores(sparse_distances, higher_is_better=True)
108+
109+
scores: dict[str, float] = {}
110+
for i, doc_id in enumerate(dense_ids):
111+
scores[doc_id] = scores.get(doc_id, 0.0) + alpha * dense_norm[i]
112+
for i, doc_id in enumerate(sparse_ids):
113+
scores[doc_id] = scores.get(doc_id, 0.0) + (1.0 - alpha) * sparse_norm[i]
114+
return scores
115+
116+
117+
def _rerank_model(
118+
query_text: str,
119+
candidate_ids: list[str],
120+
candidate_docs: dict[str, str],
121+
model_name: str,
122+
) -> dict[str, float]:
123+
"""
124+
Cross-encoder reranking: score every (query, document) pair with a
125+
cross-encoder model and return the relevance scores.
126+
127+
Requires: ``pip install sentence-transformers``
128+
"""
129+
from sentence_transformers import CrossEncoder
130+
131+
model = CrossEncoder(model_name)
132+
pairs = [(query_text, candidate_docs[doc_id]) for doc_id in candidate_ids]
133+
raw_scores: list[float] = model.predict(pairs).tolist()
134+
return dict(zip(candidate_ids, raw_scores, strict=True))
135+
136+
137+
def perform_hybrid_search(
138+
collection,
139+
query_text: str,
140+
top_k: int = 5,
141+
rerank: RerankMethod = "rrf",
142+
*,
143+
rrf_k: int = 60,
144+
alpha: float = 0.5,
145+
rerank_model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2",
146+
) -> dict[str, Any]:
147+
"""
148+
Hybrid search combining dense and sparse retrieval with pluggable reranking.
149+
150+
Args:
151+
collection: pyseekdb collection (must have both dense and sparse indexes).
152+
query_text: The query string.
153+
top_k: Number of final results to return.
154+
rerank: Reranking strategy:
155+
- ``"rrf"``: Reciprocal Rank Fusion (rank-based, no model).
156+
- ``"score"``: Weighted score fusion after min-max normalization.
157+
- ``"model"``: Cross-encoder reranker (best quality, needs
158+
``sentence-transformers``).
159+
rrf_k: RRF constant (only used when ``rerank="rrf"``). Default 60.
160+
alpha: Weight for dense scores in score fusion (only used when
161+
``rerank="score"``). ``0.0`` = sparse only, ``1.0`` = dense only.
162+
rerank_model: HuggingFace cross-encoder model name (only used when
163+
``rerank="model"``).
164+
165+
Returns:
166+
``{"ids": [...], "documents": [...], "scores": [...]}``
167+
"""
168+
# 1. Retrieve candidates from both indexes
169+
dense_results = collection.query(query_texts=[query_text], n_results=top_k)
170+
sparse_results = collection.query(
171+
query_texts=[query_text],
172+
query_key=K.SPARSE_EMBEDDING,
173+
n_results=top_k,
174+
)
175+
176+
dense_ids: list[str] = dense_results["ids"][0] if dense_results["ids"] else []
177+
sparse_ids: list[str] = sparse_results["ids"][0] if sparse_results["ids"] else []
178+
179+
# 2. Rerank
180+
if rerank == "rrf":
181+
scores = _rerank_rrf(dense_ids, sparse_ids, dense_results, sparse_results, rrf_k)
182+
elif rerank == "score":
183+
scores = _rerank_score(dense_ids, sparse_ids, dense_results, sparse_results, alpha)
184+
elif rerank == "model":
185+
all_ids = list(dict.fromkeys(dense_ids + sparse_ids))
186+
if not all_ids:
187+
return {"ids": [], "documents": [], "scores": []}
188+
fetched = collection.get(ids=all_ids)
189+
id_to_doc = dict(zip(fetched["ids"], fetched["documents"], strict=True))
190+
scores = _rerank_model(query_text, all_ids, id_to_doc, rerank_model)
191+
else:
192+
raise ValueError(f"Unknown rerank method: {rerank!r}. Use 'rrf', 'score', or 'model'.")
193+
194+
# 3. Sort by score descending, take top_k
195+
sorted_ids = sorted(scores, key=scores.get, reverse=True)[:top_k]
196+
197+
if not sorted_ids:
198+
return {"ids": [], "documents": [], "scores": []}
199+
200+
# 4. Fetch documents in final order
201+
fetched = collection.get(ids=sorted_ids)
202+
id_to_doc = dict(zip(fetched["ids"], fetched["documents"], strict=True))
203+
204+
return {
205+
"ids": sorted_ids,
206+
"documents": [id_to_doc.get(sid, "") for sid in sorted_ids],
207+
"scores": [round(scores[sid], 6) for sid in sorted_ids],
208+
}
209+
210+
211+
# =====================================================================
212+
# Demo: hybrid search with all three reranking strategies
213+
# =====================================================================
214+
215+
hybrid_collection_name = "hybrid_demo"
216+
217+
schema = Schema(
218+
vector_index=HNSWConfiguration(dimension=384),
219+
sparse_vector_index=SparseVectorIndexConfig(
220+
embedding_function=BM25SparseEmbeddingFunction(),
221+
source_key=K.DOCUMENT,
222+
),
223+
)
224+
collection = client.create_collection(hybrid_collection_name, schema=schema)
225+
226+
docs = [
227+
"Apple uses advanced chips in iPhones.",
228+
"Apples are red and delicious fruits.",
229+
"Oranges are rich in Vitamin C.",
230+
"Apple pie is a popular dessert.",
231+
"The tech giant Apple released a new laptop.",
232+
]
233+
ids = [str(i) for i in range(len(docs))]
234+
collection.add(documents=docs, ids=ids)
235+
236+
query = "apple fruit"
237+
238+
# --- RRF (default, no model needed) ---
239+
print(f"\n=== Query: '{query}' | rerank='rrf' ===")
240+
results = perform_hybrid_search(collection, query, rerank="rrf")
241+
for i, doc_id in enumerate(results["ids"]):
242+
print(f" {i + 1}. ID={doc_id} score={results['scores'][i]:.6f} | {results['documents'][i]}")
243+
244+
# --- Score-based fusion (alpha=0.5 gives equal weight to dense & sparse) ---
245+
print(f"\n=== Query: '{query}' | rerank='score', alpha=0.5 ===")
246+
results = perform_hybrid_search(collection, query, rerank="score", alpha=0.5)
247+
for i, doc_id in enumerate(results["ids"]):
248+
print(f" {i + 1}. ID={doc_id} score={results['scores'][i]:.6f} | {results['documents'][i]}")
249+
250+
# --- Cross-encoder reranker (requires sentence-transformers) ---
251+
try:
252+
print(f"\n=== Query: '{query}' | rerank='model' ===")
253+
results = perform_hybrid_search(collection, query, rerank="model")
254+
for i, doc_id in enumerate(results["ids"]):
255+
print(f" {i + 1}. ID={doc_id} score={results['scores'][i]:.6f} | {results['documents'][i]}")
256+
except ImportError:
257+
print("\n(Skipping model rerank — install sentence-transformers to enable)")

pyproject.toml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pyseekdb"
3-
version = "1.1.0.post3"
3+
version = "1.2.0.dev1"
44
description = "A unified Python client for seekdb that supports embedded, server, and OceanBase."
55
readme = "README.md"
66
license = { text = "Apache-2.0" }
@@ -38,6 +38,17 @@ dev = [
3838
"ruff>=0.11.5",
3939
]
4040

41+
# Optional deps for unit tests. CI uses `uv run --no-sync pytest` so pip-installed deps persist.
42+
unit-tests = [
43+
"bm25s[full]",
44+
"openai",
45+
"boto3",
46+
"litellm",
47+
"voyageai",
48+
"sentence-transformers>=5.0",
49+
"huggingface-hub>=0.26",
50+
]
51+
4152
[tool.uv.workspace]
4253
members = ["demo/rag"]
4354

src/pyseekdb/client/admin_client.py

Lines changed: 11 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import TYPE_CHECKING, Any
1010

1111
from .database import Database
12+
from .types import _NOT_PROVIDED
1213

1314
if TYPE_CHECKING:
1415
from .client_base import (
@@ -18,21 +19,7 @@
1819
)
1920
from .collection import Collection
2021

21-
# Delay import to avoid circular import
22-
# We'll import these lazily in the functions that need them
23-
# For now, create a placeholder that we can detect and replace
24-
_PLACEHOLDER = object() # Unique placeholder object
25-
26-
27-
def _get_not_provided():
28-
"""Get the real _NOT_PROVIDED from client_base"""
29-
from .client_base import _NOT_PROVIDED
30-
31-
return _NOT_PROVIDED
32-
33-
34-
# Use placeholder for default parameter - will be replaced in function
35-
_NOT_PROVIDED = _PLACEHOLDER
22+
SchemaParam = Any # Type hint placeholder
3623
ConfigurationParam = Any # Type hint placeholder
3724
EmbeddingFunctionParam = Any # Type hint placeholder
3825

@@ -177,30 +164,22 @@ def __init__(self, server: "BaseClient") -> None:
177164
def create_collection(
178165
self,
179166
name: str,
180-
configuration: ConfigurationParam = _PLACEHOLDER,
181-
embedding_function: EmbeddingFunctionParam = _PLACEHOLDER,
167+
schema: SchemaParam = None,
168+
configuration: ConfigurationParam = _NOT_PROVIDED,
169+
embedding_function: EmbeddingFunctionParam = _NOT_PROVIDED,
182170
**kwargs,
183171
) -> "Collection":
184172
"""Proxy to server implementation - collection operations only"""
185-
# Replace placeholder with real _NOT_PROVIDED if needed
186-
real_not_provided = _get_not_provided()
187-
if configuration is _PLACEHOLDER:
188-
configuration = real_not_provided
189-
if embedding_function is _PLACEHOLDER:
190-
embedding_function = real_not_provided
191173
return self._server.create_collection(
192174
name=name,
175+
schema=schema,
193176
configuration=configuration,
194177
embedding_function=embedding_function,
195178
**kwargs,
196179
)
197180

198-
def get_collection(self, name: str, embedding_function: EmbeddingFunctionParam = _PLACEHOLDER) -> "Collection":
181+
def get_collection(self, name: str, embedding_function: EmbeddingFunctionParam = _NOT_PROVIDED) -> "Collection":
199182
"""Proxy to server implementation - collection operations only"""
200-
# Replace placeholder with real _NOT_PROVIDED if needed
201-
real_not_provided = _get_not_provided()
202-
if embedding_function is _PLACEHOLDER:
203-
embedding_function = real_not_provided
204183
return self._server.get_collection(name=name, embedding_function=embedding_function)
205184

206185
def delete_collection(self, name: str) -> None:
@@ -218,19 +197,15 @@ def has_collection(self, name: str) -> bool:
218197
def get_or_create_collection(
219198
self,
220199
name: str,
221-
configuration: ConfigurationParam = _PLACEHOLDER,
222-
embedding_function: EmbeddingFunctionParam = _PLACEHOLDER,
200+
schema: SchemaParam = None,
201+
configuration: ConfigurationParam = _NOT_PROVIDED,
202+
embedding_function: EmbeddingFunctionParam = _NOT_PROVIDED,
223203
**kwargs,
224204
) -> "Collection":
225205
"""Proxy to server implementation - collection operations only"""
226-
# Replace placeholder with real _NOT_PROVIDED if needed
227-
real_not_provided = _get_not_provided()
228-
if configuration is _PLACEHOLDER:
229-
configuration = real_not_provided
230-
if embedding_function is _PLACEHOLDER:
231-
embedding_function = real_not_provided
232206
return self._server.get_or_create_collection(
233207
name=name,
208+
schema=schema,
234209
configuration=configuration,
235210
embedding_function=embedding_function,
236211
**kwargs,

0 commit comments

Comments
 (0)