Skip to content
Open
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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,11 @@ RETRIEVAL_DEFAULT_K=20
# RETRIEVAL_DEFAULT_BUDGET=20
# RETRIEVAL_RECALL_ROUTES=["phrase","terms"]
# RETRIEVAL_RECALL_ROUTES=["phrase","terms","vector"]
# RETRIEVAL_RERANKER_MODE=heuristic # heuristic | llm
# RETRIEVAL_RERANKER_MODE=heuristic # heuristic | llm | cross_encoder
# RETRIEVAL_LLM_RERANK_TOP_N=20
# RETRIEVAL_CROSS_ENCODER_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
# RETRIEVAL_CROSS_ENCODER_DEVICE= # empty = auto, or cpu/cuda
# RETRIEVAL_CROSS_ENCODER_TOP_N=20
# RETRIEVAL_VECTOR_WEIGHT=0.7
# RETRIEVAL_FTS_WEIGHT=0.3
# RETRIEVAL_MAX_CONTENT_CHARS=1200
Expand Down
28 changes: 28 additions & 0 deletions docs/en/concepts/reranker-benchmark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Reranker benchmark

This smoke benchmark compares the built-in heuristic and cross-encoder paths on
five small support/operations queries. Each query has five recalled candidates
and one labeled relevant candidate. It measures ranking recall and reranking
latency only; model download and startup are excluded.

Run it with:

```bash
uv run --extra rerank python scripts/benchmark_rerankers.py --device cpu --rounds 5
```

The fixture is intentionally small and deterministic, so it is useful for
regression checks rather than as a general model-quality claim. Replace the
cases in the script with domain-specific labeled candidates before selecting a
production model.

## Reference result

Results will vary by CPU, device, model cache, and candidate length. The table
below records a five-round run using Python 3.13 on a Windows x86-64 CPU with
`cross-encoder/ms-marco-MiniLM-L-6-v2` after one warm-up batch.

| Reranker | Recall@1 | Recall@3 | Median latency/query |
|----------|----------|----------|----------------------|
| Heuristic | 0.000 | 0.600 | 0.08 ms |
| Cross-encoder | 0.200 | 1.000 | 15.83 ms |
22 changes: 21 additions & 1 deletion docs/en/concepts/retrieval-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ All active routes run in parallel; their candidate sets are merged before rerank

## Reranking

After recall, candidates are scored and ranked. Two modes:
After recall, candidates are scored and ranked. Three modes:

### Heuristic reranker (default)

Expand All @@ -79,6 +79,26 @@ RETRIEVAL_RERANKER_MODE=llm
RETRIEVAL_LLM_RERANK_TOP_N=20
```

### Cross-encoder reranker

Install the optional model dependency and select the local cross-encoder path:

```bash
pip install "contextseek[rerank]"
```

```env
RETRIEVAL_RERANKER_MODE=cross_encoder
RETRIEVAL_CROSS_ENCODER_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
RETRIEVAL_CROSS_ENCODER_TOP_N=20
```

The model scores all selected query/document pairs in one batch. Loading is
lazy, so heuristic and LLM configurations do not import Sentence Transformers.
Set `RETRIEVAL_CROSS_ENCODER_DEVICE=cpu` or `cuda` to override automatic device
selection. See the [reranker benchmark](reranker-benchmark.md) for a reproducible
quality/latency comparison.

---

## Layer selection: summary vs. full
Expand Down
5 changes: 4 additions & 1 deletion docs/en/reference/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,11 @@ When `SUMMARIZER_PROVIDER=llm` but no LLM is configured, the summarizer is skipp
| `RETRIEVAL_LINK_BOOST` | `0.10` | Score bonus for items with supporting links |
| `RETRIEVAL_LINK_REFUTE_PENALTY` | `0.40` | Score penalty for items with refuting links |
| `RETRIEVAL_LINK_SUPERSEDE_PENALTY` | `0.35` | Score penalty for superseded items |
| `RETRIEVAL_RERANKER_MODE` | `heuristic` | `heuristic` or `llm` |
| `RETRIEVAL_RERANKER_MODE` | `heuristic` | `heuristic`, `llm`, or `cross_encoder` |
| `RETRIEVAL_LLM_RERANK_TOP_N` | `20` | Candidate count passed to LLM reranker |
| `RETRIEVAL_CROSS_ENCODER_MODEL` | `cross-encoder/ms-marco-MiniLM-L-6-v2` | Sentence Transformers cross-encoder model |
| `RETRIEVAL_CROSS_ENCODER_DEVICE` | _(auto)_ | Model device, for example `cpu` or `cuda` |
| `RETRIEVAL_CROSS_ENCODER_TOP_N` | `20` | Candidate count passed to the cross-encoder |

## Evolution (`EVOLUTION_*`)

Expand Down
5 changes: 4 additions & 1 deletion docs/zh/reference/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,11 @@ Provider 的 API Key(`OPENAI_API_KEY`、`DASHSCOPE_API_KEY` 等)由 LangChai
| `RETRIEVAL_LINK_BOOST` | `0.10` | 有支持链接的条目的得分加成 |
| `RETRIEVAL_LINK_REFUTE_PENALTY` | `0.40` | 有反驳链接的条目的得分惩罚 |
| `RETRIEVAL_LINK_SUPERSEDE_PENALTY` | `0.35` | 已被替代条目的得分惩罚 |
| `RETRIEVAL_RERANKER_MODE` | `heuristic` | `heuristic` 或 `llm` |
| `RETRIEVAL_RERANKER_MODE` | `heuristic` | `heuristic`、`llm` 或 `cross_encoder` |
| `RETRIEVAL_LLM_RERANK_TOP_N` | `20` | 传给 LLM 重排器的候选数量 |
| `RETRIEVAL_CROSS_ENCODER_MODEL` | `cross-encoder/ms-marco-MiniLM-L-6-v2` | Sentence Transformers cross-encoder 模型 |
| `RETRIEVAL_CROSS_ENCODER_DEVICE` | _(自动)_ | 模型设备,例如 `cpu` 或 `cuda` |
| `RETRIEVAL_CROSS_ENCODER_TOP_N` | `20` | 传给 cross-encoder 的候选数量 |

## 演化(`EVOLUTION_*`)

Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,13 @@ oceanbase = ["pyobvector>=0.1.0", "sqlalchemy>=2.0"]
daemon = ["watchdog>=4.0"]
seekdb = ["pyseekdb"]
powermem = ["powermem>=1.1.1"]
rerank = ["sentence-transformers>=3.0.0"]
all = [
"fastapi>=0.110.0", "uvicorn>=0.30.0", "pydantic>=2.7.0", "cryptography>=42.0.0",
"langchain-core>=0.3.0", "langchain>=0.3.0", "langgraph>=0.2.0",
"langchain-openai>=0.3.0",
"langchain-community>=0.3.0", "dashscope>=1.20.0",
"sentence-transformers>=3.0.0",
"watchdog>=4.0",
"pyseekdb",
]
Expand Down
134 changes: 134 additions & 0 deletions scripts/benchmark_rerankers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Small reproducible recall/latency benchmark for retrieval rerankers."""

from __future__ import annotations

import argparse
from dataclasses import dataclass
from statistics import median
from time import perf_counter

from contextseek.config.strategies import RetrievalStrategy
from contextseek.retrieval.components import CrossEncoderReranker, HeuristicReranker


@dataclass(frozen=True)
class Case:
query: str
relevant_id: str
documents: tuple[tuple[str, str], ...]


CASES = (
Case(
"How can I reset a forgotten password?",
"password",
(
("analytics", "Password reset analytics count forgotten-password requests."),
("billing", "Invoices can be downloaded from account billing settings."),
("password", "Use the forgot password link to receive a reset email."),
("profile", "Change your display name from the profile page."),
("session", "Active browser sessions can be reviewed by an administrator."),
),
),
Case(
"Why did the deployment run out of memory?",
"oom",
(
("policy", "The deployment memory policy was reviewed last quarter."),
("timeout", "The deployment failed because the health check timed out."),
("oom", "The container was killed after exceeding its memory limit."),
("network", "The release could not resolve the package registry hostname."),
("version", "The release changed the application version label."),
),
),
Case(
"Where do I rotate an API credential?",
"key",
(
("policy", "The API credential rotation policy is reviewed annually."),
("key", "Create and revoke access tokens from the security console."),
("logs", "Audit logs retain administrative actions for ninety days."),
("quota", "Request a higher rate limit from the usage page."),
("webhook", "Webhooks notify external systems when records change."),
),
),
Case(
"Can deleted records be recovered?",
"restore",
(
("report", "Deleted records are excluded from active-record reports."),
("export", "Export active records as a CSV file."),
("restore", "Restore soft-deleted items from the recycle bin for 30 days."),
("retention", "Archived logs are retained for compliance."),
("schema", "Custom fields can be added to record schemas."),
),
),
Case(
"How do I reduce slow database queries?",
"index",
(
("monitor", "A dashboard lists slow database queries and their duration."),
("backup", "Schedule nightly snapshots of the database."),
("index", "Add an index for columns used by frequent query filters."),
("replica", "Read replicas improve availability during maintenance."),
("access", "Database roles control which tables a user can access."),
),
),
)


def _candidates(case: Case) -> list[dict[str, object]]:
return [
{"id": item_id, "content": content, "score": 0.5, "stage": "skill"}
for item_id, content in case.documents
]


def evaluate(reranker, *, rounds: int) -> tuple[float, float, float]:
strategy = RetrievalStrategy()
top_one = 0
top_three = 0
latencies_ms: list[float] = []
for _ in range(rounds):
for case in CASES:
started = perf_counter()
ranked = reranker.rerank(
_candidates(case), query=case.query, strategy=strategy
)
latencies_ms.append((perf_counter() - started) * 1000)
ids = [str(item["id"]) for item in ranked]
top_one += case.relevant_id in ids[:1]
top_three += case.relevant_id in ids[:3]
total = len(CASES) * rounds
return top_one / total, top_three / total, median(latencies_ms)


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--model", default="cross-encoder/ms-marco-MiniLM-L-6-v2"
)
parser.add_argument("--device", default="cpu")
parser.add_argument("--rounds", type=int, default=5)
args = parser.parse_args()

cross_encoder = CrossEncoderReranker(args.model, device=args.device)
cross_encoder.rerank(
_candidates(CASES[0]), query=CASES[0].query, strategy=RetrievalStrategy()
)

print("reranker\trecall@1\trecall@3\tmedian_ms")
for name, reranker in (
("heuristic", HeuristicReranker()),
("cross_encoder", cross_encoder),
):
recall_at_one, recall_at_three, latency = evaluate(
reranker, rounds=max(1, args.rounds)
)
print(
f"{name}\t{recall_at_one:.3f}\t{recall_at_three:.3f}\t{latency:.2f}"
)


if __name__ == "__main__":
main()
8 changes: 7 additions & 1 deletion src/contextseek/client/contextseek.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,9 @@ class ContextSeek:
llm: Any | None = None
"""Optional shared LLM for advanced ranking/evolution/classification hooks."""

reranker: Any | None = None
"""Optional retrieval reranker implementing the Reranker protocol."""

llm_prompts: LLMPromptTemplates = field(default_factory=LLMPromptTemplates)
"""Prompt templates used by all LLM-assisted flows."""

Expand Down Expand Up @@ -613,7 +616,7 @@ def retrieve(
from contextseek.retrieval.orchestrator import RetrievalOrchestrator
from contextseek.retrieval.components import LLMReranker

reranker = None
reranker = self.reranker
if self._llm_rerank_enabled and self.llm is not None:
reranker = LLMReranker(
score_fn=self._score_relevance_with_llm,
Expand Down Expand Up @@ -2414,6 +2417,7 @@ def from_settings(
from contextseek.config.factory import (
build_embedder,
build_llm,
build_reranker,
build_summarizer,
)

Expand Down Expand Up @@ -2490,6 +2494,7 @@ def _seekdb_embed(text: str, _ef: Any = _seekdb_ef) -> list[float]:
llm=shared_llm,
prompt_templates=llm_prompts,
)
reranker = build_reranker(settings.retrieval)

llm_rerank_enabled = (
shared_llm is not None and settings.retrieval.reranker_mode.lower() == "llm"
Expand Down Expand Up @@ -2562,6 +2567,7 @@ def _seekdb_embed(text: str, _ef: Any = _seekdb_ef) -> list[float]:
embedder=embedder,
summarizer=summarizer,
llm=shared_llm,
reranker=reranker,
llm_prompts=llm_prompts,
evolution_engine=evolution_engine,
audit_log=audit_log,
Expand Down
8 changes: 7 additions & 1 deletion src/contextseek/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@
from contextseek.config.settings import nested_section_config
from contextseek.config.settings import settings_config
from contextseek.config.settings import to_strategy_config
from contextseek.config.factory import build_embedder, build_llm, build_summarizer
from contextseek.config.factory import (
build_embedder,
build_llm,
build_reranker,
build_summarizer,
)

__all__ = [
"EvolutionStrategy",
Expand All @@ -36,6 +41,7 @@
"WriteStrategy",
"build_embedder",
"build_llm",
"build_reranker",
"build_summarizer",
"default_strategy_config",
"HYBRID_RETRIEVAL_STRATEGY",
Expand Down
25 changes: 25 additions & 0 deletions src/contextseek/config/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from contextseek.config.settings import (
EmbeddingSettings,
LLMSettings,
RetrievalSettings,
SummarizerSettings,
)

Expand Down Expand Up @@ -217,9 +218,33 @@ def build_summarizer(
return None


def build_reranker(settings: RetrievalSettings) -> Any | None:
"""Build the configured non-LLM reranker.

``None`` preserves the existing heuristic and LLM assembly paths. The
cross-encoder model itself is loaded lazily on the first retrieval.
"""
mode = settings.reranker_mode.strip().lower().replace("-", "_")
if mode in {"heuristic", "llm"}:
return None
if mode == "cross_encoder":
from contextseek.retrieval.components import CrossEncoderReranker

return CrossEncoderReranker(
settings.cross_encoder_model,
device=settings.cross_encoder_device or None,
top_n=max(1, int(settings.cross_encoder_top_n)),
)
raise ValueError(
f"Unknown reranker mode '{settings.reranker_mode}'. "
"Supported modes: heuristic, llm, cross_encoder."
)


__all__ = [
"build_embedder",
"build_llm",
"build_reranker",
"build_summarizer",
"resolve_embedding_dims",
]
3 changes: 3 additions & 0 deletions src/contextseek/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,9 @@ class RetrievalSettings(BaseSettings):
)
reranker_mode: str = "heuristic"
llm_rerank_top_n: int = 20
cross_encoder_model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
cross_encoder_device: str = ""
cross_encoder_top_n: int = 20
hierarchical_alpha: float = 0.5
hierarchical_max_rounds: int = 24
hierarchical_convergence_rounds: int = 3
Expand Down
2 changes: 1 addition & 1 deletion src/contextseek/config/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class RetrievalStrategy:
importance_floor: float = 0.1
# Geo decay: distance decay unit in km for reranker spatial penalty
distance_decay_km: float = 1.0
# Rerank mode: "heuristic" (default) or "llm"
# Rerank mode: "heuristic" (default), "llm", or "cross_encoder"
reranker_mode: str = "heuristic"
# Limit number of candidates scored by LLM in reranking
llm_rerank_top_n: int = 20
Comment on lines +68 to 71
Expand Down
2 changes: 2 additions & 0 deletions src/contextseek/retrieval/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Retrieval pipeline exports."""

from contextseek.retrieval.components import DefaultRecallRoute
from contextseek.retrieval.components import CrossEncoderReranker
from contextseek.retrieval.components import HeuristicReranker
from contextseek.retrieval.components import RecallQuery
from contextseek.retrieval.components import RecallRoute
Expand All @@ -10,6 +11,7 @@

__all__ = [
"DefaultRecallRoute",
"CrossEncoderReranker",
"HeuristicReranker",
"RecallQuery",
"RecallRoute",
Expand Down
Loading