Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ All notable changes to dynavec are documented here. This project adheres to
## [Unreleased]

### Added
- **Multi-Query and HyDE fusion retrievers** (#215) — `MultiQueryRetriever` (concurrent
reformulation search with RRF fusion) and `HyDERetriever` (document-side hypothetical answer
embedding with single-passage, centroid multi-passage averaging, and fusion strategies).
- **`warm_cache()`** (#194) — pre-populate the query cache from a list of common queries.
- **Learned RRF fusion weights** (#204) — `RRFWeightFitter` fits per-retriever RRF
weights by maximizing nDCG over labeled queries.
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,44 @@ LlamaIndex, CrewAI, and Strands adapters are on the roadmap; the core client wor

---

## Query expansion: Multi-Query and HyDE

Single-query vector search frequently misses relevant documents when queries are short, colloquial, or use different terminology than the corpus. Dynavec provides two first-class query expansion adapters:

### MultiQueryRetriever
Expands a user query into diverse reformulations using an LLM, fans out searches concurrently, and merges results via Reciprocal Rank Fusion (RRF):

```python
from dynavec import MultiQueryRetriever

retriever = MultiQueryRetriever(
db.namespace("docs"),
generate_queries=lambda q: my_llm.generate_variations(q, n=3),
top_k=4,
)
hits = retriever.search("car won't start")
```

### HyDERetriever
Hypothetical Document Embeddings (HyDE) asks an LLM to generate an answer passage, embeds it as a document (via `embed_documents`), and retrieves nearest neighbours. Supports single-passage or multi-passage Centroid averaging (`strategy="average"`) and multi-search fusion (`strategy="fuse"`):

```python
from dynavec import HyDERetriever

hyde = HyDERetriever(
db.namespace("docs"),
generate_hypothetical=lambda q: my_llm.generate_answer(q),
top_k=4,
strategy="average",
include_original=True,
)
hits = hyde.search("explain dynamo db storage pricing breakdown")
```

You can also instantiate retrievers directly via `db.as_multiquery_retriever(...)` or `db.namespace("docs").as_hyde_retriever(...)`.

---

## Choosing an embedding dimension

Embedding dimension trades off recall against storage cost and latency. A larger dimension usually gives higher recall, but the right choice is the **smallest dimension that meets your recall target** — not the largest. See [EMBEDDING_DIMENSIONS.md](docs/EMBEDDING_DIMENSIONS.md) for a comparison table, the S3 Vectors 4096-dim ceiling, and a step-by-step picking guide.
Expand Down
145 changes: 145 additions & 0 deletions examples/query_expansion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Multi-Query and HyDE retrieval -- runs offline, no AWS or LLM needed.

python examples/query_expansion.py

The corpus below never uses the words in the user's query ("car won't start"),
which is the classic vocabulary-mismatch problem where single-query search fails.
Both retrievers take plain callables for the LLM step; here we use canned stand-ins.
In production, swap in any LLM (OpenAI, Anthropic, Ollama, LiteLLM):

def generate_queries(q: str) -> list[str]:
reply = my_llm(f"Give 3 alternative search queries for: {q}. One per line.")
return [line.strip() for line in reply.splitlines() if line.strip()]

def generate_hypothetical(q: str) -> str:
return my_llm(f"Write a short passage that answers: {q}")

retriever = db.namespace("kb").as_multiquery_retriever(generate_queries, top_k=3)
"""

from __future__ import annotations

import math
import re
import zlib

import dynavec.client as cm
from dynavec import Document, Dynavec, DynavecConfig, HyDERetriever, MultiQueryRetriever
from dynavec.embeddings.base import Embedder

DIM = 128


class BagOfWordsEmbedder(Embedder):
"""Toy lexical embedder: hashed bag of words (requires word overlap)."""

dimension = DIM

def embed_documents(self, texts):
out = []
for text in texts:
vec = [0.0] * DIM
for word in re.findall(r"[a-z']+", text.lower()):
vec[zlib.crc32(word.encode()) % DIM] += 1.0
out.append(vec)
return out


# --- In-memory stand-ins for S3 Vectors + DynamoDB (demo only) ----------------
class _S3(cm.S3VectorsStore):
def __init__(self, config, boto_session=None):
self.config, self._store = config, {}

def put_vectors(self, vectors):
for key, vec, meta in vectors:
self._store[key] = (list(vec), dict(meta))

def query(self, query_vector, top_k, filter=None, **_):
def dist(v):
dot = sum(a * b for a, b in zip(query_vector, v))
norm = (math.sqrt(sum(a * a for a in query_vector)) or 1e-9) * (
math.sqrt(sum(b * b for b in v)) or 1e-9
)
return 1.0 - dot / norm

rows = sorted(((k, dist(v)) for k, (v, _) in self._store.items()), key=lambda r: r[1])
return [{"key": k, "distance": d} for k, d in rows[:top_k]]


class _DDB(cm.DynamoDBStore):
def __init__(self, config, boto_session=None):
self.config, self._store = config, {}

def put_many(self, namespace, items):
for doc_id, text, meta in items:
self._store[(namespace, doc_id)] = {"text": text, "metadata": dict(meta)}

def get_many(self, namespace, ids):
return {i: self._store[(namespace, i)] for i in ids if (namespace, i) in self._store}


CORPUS = {
"ignition": "Diagnosing engine ignition failure: check the battery, starter motor and spark plugs.",
"brakes": "Replacing worn brake pads and rotors on a front disc brake assembly.",
"tires": "Rotating tires and checking tread depth improves handling and fuel economy.",
"oil": "Changing engine oil and the oil filter every five thousand miles.",
"coolant": "Flushing the radiator coolant prevents the engine from overheating in summer.",
}


def show(title: str, hits: list) -> None:
print(f"\n{title}")
for rank, h in enumerate(hits, 1):
print(f" {rank}. [{h.id:<9}] {h.text[:65]}... (score: {h.score:.4f})")


def main() -> None:
cm.S3VectorsStore, cm.DynamoDBStore = _S3, _DDB # demo only: no AWS
cfg = DynavecConfig(vector_bucket="demo", index="demo", table="demo", dimension=DIM)
db = Dynavec(cfg, embedder=BagOfWordsEmbedder())
db.upsert([Document(id=k, text=v) for k, v in CORPUS.items()], namespace="kb")
kb = db.namespace("kb")

query = "car won't start"
print(f"User Query: {query!r}")
print("Corpus documents never mention 'car' or 'start'. Notice how each method performs:")

# 1. Plain search (misses the correct document)
show("1. Plain search (top 2):", kb.search(query, top_k=2))

# 2. MultiQueryRetriever (reformulates into technical terminology)
multi = MultiQueryRetriever(
kb,
generate_queries=lambda q: [
"engine ignition failure starter motor",
"dead battery spark plugs",
],
top_k=2,
)
show("2. MultiQueryRetriever (top 2):", multi.search(query))

# 3. HyDERetriever (generates a hypothetical answer passage)
hyde = kb.as_hyde_retriever(
generate_hypothetical=lambda q: (
"When an engine fails to crank, the issue is typically a depleted battery, "
"a worn starter motor, or fouled spark plugs in the ignition system."
),
top_k=2,
)
show("3. HyDERetriever - Single Passage (top 2):", hyde.search(query))

# 4. HyDERetriever with multi-passage Centroid averaging
hyde_multi = HyDERetriever(
kb,
generate_hypothetical=lambda q: [
"A no-start condition is caused by faulty battery connections or a dead starter motor.",
"Inspect the engine ignition system, spark plug wiring, and starter relay switch.",
],
strategy="average",
top_k=2,
)
show("4. HyDERetriever - Centroid Multi-Passage (top 2):", hyde_multi.search(query))


if __name__ == "__main__":
main()
8 changes: 8 additions & 0 deletions src/dynavec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@
maximal_marginal_relevance,
reciprocal_rank_fusion,
)
from .retrievers import (
HyDERetriever,
MultiQueryRetriever,
QueryExpansionRetriever,
)
from .spfresh import (
Partition,
SPFreshConfig,
Expand Down Expand Up @@ -92,6 +97,9 @@
"warm_cache",
"reciprocal_rank_fusion",
"maximal_marginal_relevance",
"QueryExpansionRetriever",
"MultiQueryRetriever",
"HyDERetriever",
"RRFWeightFitter",
"HotTier",
"Partition",
Expand Down
38 changes: 38 additions & 0 deletions src/dynavec/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,44 @@ def search_many(
]
return [f.result() for f in futures]

def as_multiquery_retriever(
self,
generate_queries=None,
*,
llm_generate_queries=None,
namespace: str = "default",
**kw,
):
"""Create a :class:`~dynavec.retrievers.MultiQueryRetriever` bound to this client."""
from .retrievers import MultiQueryRetriever

return MultiQueryRetriever(
self,
generate_queries=generate_queries,
llm_generate_queries=llm_generate_queries,
namespace=namespace,
**kw,
)

def as_hyde_retriever(
self,
generate_hypothetical=None,
*,
llm_generate_hypothetical=None,
namespace: str = "default",
**kw,
):
"""Create a :class:`~dynavec.retrievers.HyDERetriever` bound to this client."""
from .retrievers import HyDERetriever

return HyDERetriever(
self,
generate_hypothetical=generate_hypothetical,
llm_generate_hypothetical=llm_generate_hypothetical,
namespace=namespace,
**kw,
)

def _resolve_query_vector(
self, query: str | None, vector: list[float] | None
) -> list[float]:
Expand Down
7 changes: 6 additions & 1 deletion src/dynavec/integrations/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@

from ..client import Dynavec
from ..namespace import NamespaceView
from ..retrievers import QueryExpansionRetriever


def make_retriever_fn(
source: Dynavec | NamespaceView,
source: Dynavec | NamespaceView | QueryExpansionRetriever,
*,
top_k: int = 4,
namespace: str = "default",
Expand All @@ -29,8 +30,12 @@ def make_retriever_fn(
Works as-is in LangGraph nodes, CrewAI tools, Strands tools, or any
function-calling agent.
"""
if isinstance(source, QueryExpansionRetriever) and rescore is not None:
raise ValueError("rescore is not supported with query-expansion retrievers")

def _search(query: str):
if isinstance(source, QueryExpansionRetriever):
return source.search(query, top_k=top_k, filter=filter)
if isinstance(source, NamespaceView):
return source.search(query, top_k=top_k, filter=filter, rescore=rescore)
return source.search(
Expand Down
26 changes: 26 additions & 0 deletions src/dynavec/namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,31 @@ def get(self, ids, **kw) -> list[SearchResult]:
def delete(self, ids, **kw) -> None:
return self._db.delete(ids, namespace=self._ns, **kw)

def as_multiquery_retriever(
self, generate_queries=None, *, llm_generate_queries=None, **kw
):
"""Create a :class:`~dynavec.retrievers.MultiQueryRetriever` pinned to this namespace."""
from .retrievers import MultiQueryRetriever

return MultiQueryRetriever(
self,
generate_queries=generate_queries,
llm_generate_queries=llm_generate_queries,
**kw,
)

def as_hyde_retriever(
self, generate_hypothetical=None, *, llm_generate_hypothetical=None, **kw
):
"""Create a :class:`~dynavec.retrievers.HyDERetriever` pinned to this namespace."""
from .retrievers import HyDERetriever

return HyDERetriever(
self,
generate_hypothetical=generate_hypothetical,
llm_generate_hypothetical=llm_generate_hypothetical,
**kw,
)

def __repr__(self) -> str:
return f"NamespaceView(namespace={self._ns!r})"
17 changes: 17 additions & 0 deletions src/dynavec/retrieval.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,20 @@ def _norm(x: np.ndarray) -> np.ndarray:
unselected[best_idx] = False

return [usable[i] for i in selected]


# Re-export query expansion retrievers for convenience
from .retrievers import ( # noqa: E402
HyDERetriever,
MultiQueryRetriever,
QueryExpansionRetriever,
)

__all__ = [
"distance_to_score",
"reciprocal_rank_fusion",
"maximal_marginal_relevance",
"QueryExpansionRetriever",
"MultiQueryRetriever",
"HyDERetriever",
]
Loading
Loading