* feat: introduce hindsight-api-slim and hindsight-all-slim packages Closes #552 - Move all source code from hindsight-api/ to new hindsight-api-slim/ - hindsight-api-slim has heavy ML deps (torch, sentence-transformers, transformers, einops, flashrank, mlx, mlx-lm, safetensors) and pg0-embedded as optional extras: [local-ml], [embedded-db], [all] - hindsight-api becomes a zero-code meta-package depending on hindsight-api-slim[all] for full backward compatibility - Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed - hindsight-all updated to depend on hindsight-api-slim[all] - pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db] - Dockerfile: replace sed hack with proper uv sync --extra flags - Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and all path references throughout the repo * refactor: rename hindsight/ directory to hindsight-all/ * docs: document hindsight-api-slim and hindsight-all-slim package variants Add package variants table and extras explanation to installation.md * docs: remove emojis from installation.md, use professional tone * docs: link Docker slim variant to pip package variants section * docs: consolidate Docker image variants into single table * ci: fix working-directory paths after package restructure - Replace all hindsight-api → hindsight-api-slim in test.yml - Replace hindsight → hindsight-all in test.yml - Add --extra embedded-db to test-embed API install step * ci: add local-ml and embedded-db extras to API sync steps These extras were previously implicit in the old hindsight-api package (which bundled everything). Now that hindsight-api-slim uses optional extras, we must explicitly request local-ml and embedded-db in CI. * ci: add API install step with embedded-db to test-embed smoke test The smoke test starts hindsight-api as a daemon, which requires pg0-embedded. Add a dedicated install step for hindsight-api-slim with embedded-db extra so the daemon can start successfully. * ci: remove --no-install-project when using optional extras When --no-install-project is combined with --extra, the optional deps are not installed because extras require the project to be active. Remove --no-install-project from steps that need local-ml or embedded-db. * ci: fix ordering of uv sync steps to preserve optional extras When uv sync runs for a different workspace member, it removes optional extras installed for other members. Fix by always running extra-requiring API sync last, after other workspace member syncs. Also remove --no-install-project from embedded-db sync in test-embed, as --no-install-project prevents optional extras from being active. * ci: add local-ml extra to test-embed API install for smoke test The smoke test starts the full API server which needs sentence-transformers for local embeddings (default provider). Add local-ml extra to the install. * ci: simplify extras with --all-extras and add slim pip smoke test - Replace explicit --extra local-ml --extra embedded-db with --all-extras for cleaner, more maintainable sync steps - Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without local ML models, using Cohere for embeddings/reranking (mirrors Docker slim smoke test approach) * ci: simplify slim smoke test to health check only (mirrors Docker test)
177 lines
6.6 KiB
Python
177 lines
6.6 KiB
Python
"""
|
||
Cross-encoder neural reranking for search results.
|
||
"""
|
||
|
||
from datetime import datetime, timezone
|
||
|
||
from .types import MergedCandidate, ScoredResult
|
||
|
||
UTC = timezone.utc
|
||
|
||
# Multiplicative boost alphas for recency and temporal proximity.
|
||
# Each signal contributes at most ±(alpha/2) relative adjustment to the base CE score,
|
||
# so the max combined boost is (1 + alpha/2)^2 ≈ +21% and min is (1 - alpha/2)^2 ≈ -19%.
|
||
_RECENCY_ALPHA: float = 0.2
|
||
_TEMPORAL_ALPHA: float = 0.2
|
||
|
||
|
||
def apply_combined_scoring(
|
||
scored_results: list[ScoredResult],
|
||
now: datetime,
|
||
recency_alpha: float = _RECENCY_ALPHA,
|
||
temporal_alpha: float = _TEMPORAL_ALPHA,
|
||
) -> None:
|
||
"""Apply combined scoring to a list of ScoredResults in-place.
|
||
|
||
Uses the cross-encoder score as the primary relevance signal, with recency
|
||
and temporal proximity applied as multiplicative boosts. This ensures the
|
||
influence of these secondary signals is always proportional to the base
|
||
relevance score, regardless of the cross-encoder model's score calibration.
|
||
|
||
Formula::
|
||
|
||
recency_boost = 1 + recency_alpha * (recency - 0.5) # in [1-α/2, 1+α/2]
|
||
temporal_boost = 1 + temporal_alpha * (temporal - 0.5) # in [1-α/2, 1+α/2]
|
||
combined_score = cross_encoder_score_normalized * recency_boost * temporal_boost
|
||
|
||
Temporal proximity is treated as neutral (0.5) when not set by temporal retrieval,
|
||
so temporal_boost collapses to 1.0 for non-temporal queries.
|
||
|
||
Args:
|
||
scored_results: Results from the cross-encoder reranker. Mutated in place.
|
||
now: Current UTC datetime for recency calculation.
|
||
recency_alpha: Max relative recency adjustment (default 0.2 → ±10%).
|
||
temporal_alpha: Max relative temporal adjustment (default 0.2 → ±10%).
|
||
"""
|
||
if now.tzinfo is None:
|
||
now = now.replace(tzinfo=UTC)
|
||
|
||
for sr in scored_results:
|
||
# Recency: linear decay over 365 days → [0.1, 1.0]; neutral 0.5 if no date.
|
||
sr.recency = 0.5
|
||
if sr.retrieval.occurred_start:
|
||
occurred = sr.retrieval.occurred_start
|
||
if occurred.tzinfo is None:
|
||
occurred = occurred.replace(tzinfo=UTC)
|
||
days_ago = (now - occurred).total_seconds() / 86400
|
||
sr.recency = max(0.1, min(1.0, 1.0 - (days_ago / 365)))
|
||
|
||
# Temporal proximity: meaningful only for temporal queries; neutral otherwise.
|
||
sr.temporal = sr.retrieval.temporal_proximity if sr.retrieval.temporal_proximity is not None else 0.5
|
||
|
||
# RRF: kept at 0.0 for trace continuity but excluded from scoring.
|
||
# RRF is batch-relative (min-max normalised) and redundant after reranking.
|
||
sr.rrf_normalized = 0.0
|
||
|
||
recency_boost = 1.0 + recency_alpha * (sr.recency - 0.5)
|
||
temporal_boost = 1.0 + temporal_alpha * (sr.temporal - 0.5)
|
||
sr.combined_score = sr.cross_encoder_score_normalized * recency_boost * temporal_boost
|
||
sr.weight = sr.combined_score
|
||
|
||
|
||
class CrossEncoderReranker:
|
||
"""
|
||
Neural reranking using a cross-encoder model.
|
||
|
||
Configured via environment variables (see cross_encoder.py).
|
||
Default local model is cross-encoder/ms-marco-MiniLM-L-6-v2.
|
||
"""
|
||
|
||
def __init__(self, cross_encoder=None):
|
||
"""
|
||
Initialize cross-encoder reranker.
|
||
|
||
Args:
|
||
cross_encoder: CrossEncoderModel instance. If None, creates one from
|
||
environment variables (defaults to local provider)
|
||
"""
|
||
if cross_encoder is None:
|
||
from hindsight_api.engine.cross_encoder import create_cross_encoder_from_env
|
||
|
||
cross_encoder = create_cross_encoder_from_env()
|
||
self.cross_encoder = cross_encoder
|
||
self._initialized = False
|
||
|
||
async def ensure_initialized(self):
|
||
"""Ensure the cross-encoder model is initialized (for lazy initialization)."""
|
||
if self._initialized:
|
||
return
|
||
|
||
import asyncio
|
||
|
||
cross_encoder = self.cross_encoder
|
||
# For local providers, run in thread pool to avoid blocking event loop
|
||
if cross_encoder.provider_name == "local":
|
||
loop = asyncio.get_event_loop()
|
||
await loop.run_in_executor(None, lambda: asyncio.run(cross_encoder.initialize()))
|
||
else:
|
||
await cross_encoder.initialize()
|
||
self._initialized = True
|
||
|
||
async def rerank(self, query: str, candidates: list[MergedCandidate]) -> list[ScoredResult]:
|
||
"""
|
||
Rerank candidates using cross-encoder scores.
|
||
|
||
Args:
|
||
query: Search query
|
||
candidates: Merged candidates from RRF
|
||
|
||
Returns:
|
||
List of ScoredResult objects sorted by cross-encoder score
|
||
"""
|
||
if not candidates:
|
||
return []
|
||
|
||
# Prepare query-document pairs with date information
|
||
pairs = []
|
||
for candidate in candidates:
|
||
retrieval = candidate.retrieval
|
||
|
||
# Use text + context for better ranking
|
||
doc_text = retrieval.text
|
||
if retrieval.context:
|
||
doc_text = f"{retrieval.context}: {doc_text}"
|
||
|
||
# Add formatted date information for temporal awareness
|
||
if retrieval.occurred_start:
|
||
occurred_start = retrieval.occurred_start
|
||
|
||
# Format in two styles for better model understanding
|
||
# 1. ISO format: YYYY-MM-DD
|
||
date_iso = occurred_start.strftime("%Y-%m-%d")
|
||
|
||
# 2. Human-readable: "June 5, 2022"
|
||
date_readable = occurred_start.strftime("%B %d, %Y")
|
||
|
||
# Prepend date to document text
|
||
doc_text = f"[Date: {date_readable} ({date_iso})] {doc_text}"
|
||
|
||
pairs.append([query, doc_text])
|
||
|
||
# Get cross-encoder scores
|
||
scores = await self.cross_encoder.predict(pairs)
|
||
|
||
# Normalize scores using sigmoid to [0, 1] range
|
||
# Cross-encoder returns logits which can be negative
|
||
import numpy as np
|
||
|
||
def sigmoid(x):
|
||
return 1 / (1 + np.exp(-x))
|
||
|
||
normalized_scores = [sigmoid(score) for score in scores]
|
||
|
||
# Create ScoredResult objects with cross-encoder scores
|
||
scored_results = []
|
||
for candidate, raw_score, norm_score in zip(candidates, scores, normalized_scores):
|
||
scored_result = ScoredResult(
|
||
candidate=candidate,
|
||
cross_encoder_score=float(raw_score),
|
||
cross_encoder_score_normalized=float(norm_score),
|
||
weight=float(norm_score), # Initial weight is just cross-encoder score
|
||
)
|
||
scored_results.append(scored_result)
|
||
|
||
# Sort by cross-encoder score
|
||
scored_results.sort(key=lambda x: x.weight, reverse=True)
|
||
|
||
return scored_results
|