windlass.providers.retrievers.hybrid¶
hybrid
¶
Hybrid retrieval — dense and sparse, fused.
Vector search and BM25 fail differently. Embeddings understand "how do I cancel
my plan" ≈ "subscription termination"; BM25 finds the literal string E1042
that no embedding will ever place near your query. Running both and fusing the
results measurably beats either alone on virtually every benchmark.
Windlass fuses with Reciprocal Rank Fusion, which combines ranks rather than scores. That matters: a cosine similarity of 0.81 and a BM25 score of 14.3 are not comparable quantities, and normalising them requires corpus statistics that change every time you ingest. Ranks need no normalisation at all.
Example
from windlass.providers.embeddings.hash import HashEmbedder from windlass.providers.vectordb.memory import InMemoryVectorStore from windlass.providers.retrievers.vector import VectorRetriever from windlass.providers.retrievers.bm25 import BM25Retriever from windlass.core.types import Chunk emb, store = HashEmbedder(dimensions=128), InMemoryVectorStore(dimensions=128) chunks = [Chunk(content="error E1042 socket closed"), Chunk(content="cats purr")] for c, v in zip(chunks, emb.embed([c.content for c in chunks])): ... c.embedding = v _ = store.add(chunks) lexical = BM25Retriever(); _ = lexical.index(chunks) hybrid = HybridRetriever( ... retrievers=[VectorRetriever(embedder=emb, vectorstore=store), lexical] ... ) hybrid.retrieve("E1042").hits[0].chunk.content 'error E1042 socket closed'
HybridRetriever
¶
HybridRetriever(
*,
retrievers: Sequence[Retriever] | None = None,
weights: Sequence[float] | None = None,
top_k: int = 5,
fetch_k: int = 20,
rrf_k: int = 60,
mode: str = "rrf",
**config: Any
)
Bases: Retriever
Fuses any number of retrievers into one ranking.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
retrievers
|
Sequence[Retriever] | None
|
The legs to run. Typically one dense and one sparse, but any number of any kind works — add a graph retriever or a remote search API and it fuses just the same. |
None
|
weights
|
Sequence[float] | None
|
Per-leg weights, in the same order. Defaults to equal weight. Raise the dense weight for conversational corpora, the sparse weight for technical ones full of identifiers. |
None
|
top_k
|
int
|
Default number of results. |
5
|
fetch_k
|
int
|
Candidates pulled from each leg before fusion. Should exceed
|
20
|
rrf_k
|
int
|
RRF smoothing constant. Larger values flatten the influence of the top positions. |
60
|
mode
|
str
|
|
'rrf'
|
**config
|
Any
|
Forwarded to :class: |
{}
|
Raises:
| Type | Description |
|---|---|
ConfigurationError
|
When fewer than two retrievers are supplied, or when
|
Performance
Legs run concurrently, so hybrid retrieval costs about as much wall-clock time as its slowest leg, not the sum.
Source code in src\windlass\providers\retrievers\hybrid.py
aindex
async
¶
Index chunks into every leg that maintains its own index.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
chunks
|
Sequence[Chunk]
|
Chunks to index. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The largest count reported by any leg — legs share one corpus, so |
int
|
summing would over-report. |
Source code in src\windlass\providers\retrievers\hybrid.py
aretrieve_chunks
async
¶
aretrieve_chunks(
query: str, k: int, *, filters: MetadataFilter | None = None, **kwargs: Any
) -> list[ScoredChunk]
Run every leg concurrently and fuse the rankings.
A leg that fails is logged and skipped rather than failing the whole retrieval — degraded results beat no results.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
query
|
str
|
The search query. |
required |
k
|
int
|
How many results to return. |
required |
filters
|
MetadataFilter | None
|
Metadata constraints, passed to every leg. |
None
|
**kwargs
|
Any
|
Forwarded to every leg. |
{}
|
Returns:
| Type | Description |
|---|---|
list[ScoredChunk]
|
The fused ranking. |
Source code in src\windlass\providers\retrievers\hybrid.py
describe
¶
Return a summary including each leg and its weight.