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
10 changes: 6 additions & 4 deletions KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@
- **Guard:** `tests/test_reranker.py` — `test_sigmoid_matches_reference_values` (4 кейса), `test_sigmoid_is_numerically_stable_at_extremes`, `test_llama_cpp_scores_normalized_to_unit_interval`, `test_llama_cpp_negative_logit_does_not_leave_unit_interval`. Проверено мутацией (`_sigmoid` → identity): **7 failed / 38 passed**; чисто — 45 passed.
- **T3 (обобщение):** иных мест с абсолютным порогом по логитам в `src/` нет. `_DEFAULT_THRESHOLD = 0.85` в `duplication.py:37` — порог по Jaccard (по определению в [0,1], `clamp` на строке 136), другой механизм.

## 2026-09-27 — P2: целевой файл не доходит до финального пула; причина не установлена (Open)
## 2026-09-27 — P2: целевой файл не доходит до финального пула (Root Cause установлен, fix в PR)

- **Симптом:** для запроса P2 (`hybrid_search_async reciprocal_rank_fusion FTS5 BM25`) целевой `src/core/search/engine.py` не найден. Top-хиты — собственные артефакты эксперимента: `experiments/**/*.txt`, `results.json`, `docs/zh/SEARCH_PIPELINE.md`. Реранкер ни при чём — цели нет в пуле ещё до него.
- **Root Cause: НЕ УСТАНОВЛЕН.** Зафиксировано открытое противоречие: отдельный standalone-прогон BM25 вернул цель на **rank 0**, что несовместимо с утверждением «цель не находится вовсе». Расхождение между standalone BM25 и путём внутри `hybrid_search_async` не изучено. **Причину не утверждать** до разбора построения пула и RRF-слияния.
- **Побочно (Verified):** индекс содержит вывод собственных экспериментов, что загрязняет lexical-выдачу по общим терминам — это отдельная проблема индексации, не фильтра.
- **Что нужно:** разобрать построение pre-rerank пула и слияние RRF; выяснить, почему BM25 rank-0 не доходит до финального пула.
- **Root Cause (Verified чтением кода + синтетической регрессией):** RRF-консенсус размывает однотирные находки. Цель P2 — BM25 rank 0, но только одного тира → `1/(60+1)≈0.0164`; мусор из 2-3 тиров на средних рангах накапливает 2-3x → `rrf_results[:limit]` (`engine.py:746`) ампутирует цель до реранкера. MMR невиновен (reorder-only), bucket-веса фаворизируют цель (.py 1.0 vs .txt/.md 0.5), expansion держит verbatim-запрос как variants[0] — «противоречие» standalone-BM25-rank-0 vs hybrid-loss этим и разрешается: hybrid никогда не возвращает сырой BM25-порядок.
- **Fix (adopt/reranker-threshold-and-pool):** `anchor_tier_winners` (`scoring.py`) — пул = MMR-база + лидеры тиров (per_tier=1, потолок MAX_RERANKER_INPUT), дописываются в хвост после MMR; `top_n` реранкера (=limit) и путь без реранкера не меняются. Плюс top-N recall floor `MAX_RERANKER_TOPN` (default 0=off) и `threshold_calibration.calibrate_threshold` с кодовым запретом калибровки на eval (ValueError). Дефолт 0.3 не тронут — калибровать только на holdout, дизъюнктном с frozen eval-16.
- **Guard:** `tests/test_reranker_pool_and_threshold.py` (18: P2-регрессия падала до фикса, якоря/дедуп/cap/fallback/limit=0, top-N вкл/выкл/cap, F1-калибровка, 6 eval-маркеров → ValueError). Смежные: 143 passed (reranker+searcher+hardening+ubatch+bs_audit); ruff чист.
- **Остаточное:** live-проверка P2/P3 на реальном индексе+llama.cpp не выполнена в этой среде (только синтетика + залогированные скоры P3/R2); holdout-набор для калибровки не собран (нужно ≥10 запросов вне eval-16) — решение владельца.
- **Статус:** 🔧 Fixed (код+тесты) / 🔬 Open (live-валидация + holdout-калибровка).

## 2026-09-25 — Падения не фиксировались: zombie-job + глушение исключений + нет ledger (Fixed) / Open (server hard-death)

Expand Down
11 changes: 11 additions & 0 deletions src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,17 @@ class PerformanceConfig:
default_factory=lambda: float(os.getenv("DOCS_BUCKET_WEIGHT", "0.5"))
)

# Reranker top-N recall floor (adopt/reranker-threshold-and-pool):
# сколько лучших по скору чанков переживают MIN_RERANK_SCORE-фильтр
# безусловно (union с прошедшими порог). 0 = выключено (текущее поведение:
# фильтр + fallback "вернуть всё", если не прошёл никто).
# P3-прецедент: цель 0.271<0.3 отсекалась при 2 прошедших; top_n_keep>=3
# вернул бы её как 3-ю по скору. Калибровка значения — только на holdout
# (см. src/providers/reranker/threshold_calibration.py), default 0.
reranker_topn_keep: int = field(
default_factory=lambda: int(os.getenv("MAX_RERANKER_TOPN", "0"))
)

# SYSTEM PROFILE (v2.6.0+)
system_profile: str = os.getenv("SYSTEM_PROFILE", "light")

Expand Down
28 changes: 28 additions & 0 deletions src/core/search/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@
from .fts5_mixin import FTS5Mixin
from .scoring import (
_apply_co_change_boost,
anchor_tier_winners,
apply_bucket_weights,
apply_mmr_diversity,
auto_detect_intent,
reciprocal_rank_fusion,
reciprocal_rank_fusion_3way,
rrf_key,
)
from .token_savings import calculate_token_savings
from .trace import SearchTracer
Expand Down Expand Up @@ -757,6 +759,32 @@ async def hybrid_search_async(
if tracer and _mmr_before:
tracer.record_mmr(_mmr_before, pre_rerank_results, lambda_param=0.6)

# === P2-fix: лидеры тиров в pre-rerank пул (anchor_tier_winners) ===
# RRF награждает multi-tier консенсус: цель из ОДНОГО тира (P2: BM25
# rank 0) проигрывает multi-tier мусору и срезалась [:limit] до
# реранкера. Якоря дописываются ПОСЛЕ MMR (он только переупорядочивает),
# потолок — MAX_RERANKER_INPUT; top_n реранкера (=limit) не меняется.
if use_rrf:
# MMR-порядок базового среза сохраняется как есть (важен для
# пути без реранкера); якоря только дописываются в хвост —
# при живом реранкере он всё равно пересортирует по своим скорам.
_mmr_keys = {rrf_key(c) for c in pre_rerank_results}
_anchored = anchor_tier_winners(
rrf_results,
[
(unique_bm25, "bm25_score"),
(all_dense_results, "dense_score"),
(all_fts5_results, "fts5_score"),
(graph_results, "graph_score"),
],
limit,
per_tier=1,
pool_cap=MAX_RERANKER_INPUT,
)
pre_rerank_results = list(pre_rerank_results) + [
c for c in _anchored if rrf_key(c) not in _mmr_keys
]

# Мульти-провайдерный реранкинг (Ollama / LM Studio) — опциональный
# Реранкер перезаписывает final_score своими семантическими весами
_pre_rerank = list(pre_rerank_results) if tracer else None
Expand Down
79 changes: 79 additions & 0 deletions src/core/search/scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@

__all__ = [
"reciprocal_rank_fusion",
"reciprocal_rank_fusion_3way",
"rrf_key",
"anchor_tier_winners",
"auto_detect_intent",
"apply_bucket_weights",
"apply_mmr_diversity",
Expand Down Expand Up @@ -149,6 +152,82 @@ def _ingest(results, score_key):
return out


def rrf_key(item: dict) -> str:
"""Канонический ключ фьюжена ``file:chunk_index`` (единый для RRF,
дедупа тиров и якорей — расхождение форматов роняло бы сверку)."""
meta = item.get("metadata", {}) or {}
return f"{meta.get('file', '?')}:{meta.get('chunk_index', 0)}"


def anchor_tier_winners(
rrf_ranked: List[dict],
tiers: List[tuple],
limit: int,
per_tier: int = 1,
pool_cap: int = 30,
rrf_k: int = 60,
) -> List[dict]:
"""Гарантирует место в pre-rerank пуле лидерам каждого тира (P2-fix).

Мотивация (P2, verified): 3-way RRF награждает multi-tier консенсус —
цель, найденная ОДНИМ тиром (BM25 rank 0 → ``1/(60+1)``), проигрывает
мусору из 2-3 тиров (``2-3x``) и ампутируется срезом ``[:limit]`` до
реранкера. MMR невиновен (только переупорядочивает), bucket-веса
фаворизируют цель (.py=1.0 против .txt/.md=0.5).

Механика: пул = RRF top-``limit`` + недостающие лидеры тиров
(``per_tier`` голов каждого тира), взятые из полного RRF-списка;
если лидера нет даже там (сверхзагрязнённый индекс) — fused-запись
строится из сырого тир-элемента с его RRF-вкладом. Порядок RRF
сохраняется, дубли исключаются, размер ограничен ``pool_cap``.

Args:
rrf_ranked: Полный RRF-ранжированный список (до среза, ~raw_limit).
tiers: Список ``(tier_items, score_key)`` — score_key один из
``bm25_score`` / ``dense_score`` / ``fts5_score`` / ``graph_score``.
limit: Базовый размер пула (RRF top-limit входит безусловно).
per_tier: Сколько голов тира закреплять (default 1).
pool_cap: Жёсткий потолок пула (реранкер ~37ms/текст).
rrf_k: Та же константа RRF, что при фьюжене.

Returns:
Пул для реранкера (схема записей = RRF, с ``final_score``).
"""
if limit <= 0:
return [] # контракт hybrid_search_async: limit=0 -> пустой пул
pool = list(rrf_ranked[:limit])
pool_keys = {rrf_key(c) for c in pool}
ranked_by_key = {rrf_key(c): c for c in rrf_ranked}

for tier_items, score_key in tiers:
for rank, item in enumerate(tier_items[:per_tier], 1):
if len(pool) >= pool_cap:
return pool
key = rrf_key(item)
if key in pool_keys:
continue
if key in ranked_by_key:
pool.append(ranked_by_key[key])
else:
# Лидер тира вне RRF-списка: восстанавливаем fused-запись
# с его собственным RRF-вкладом 1/(k+rank).
contrib = 1.0 / (rrf_k + rank)
entry = {
"text": item.get("text", ""),
"metadata": item.get("metadata", {}),
"bm25_score": 0.0,
"dense_score": 0.0,
"fts5_score": 0.0,
"graph_score": 0.0,
"final_score": contrib,
}
if score_key in entry:
entry[score_key] = contrib
pool.append(entry)
pool_keys.add(key)
return pool


def auto_detect_intent(query: str) -> str:
"""Авто-определение intent по тексту запроса (v3.2.1 B1).

Expand Down
37 changes: 35 additions & 2 deletions src/providers/reranker/multi_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

import asyncio
import logging
import math
import time
from typing import Any, Dict, List, Optional

Expand Down Expand Up @@ -84,10 +85,24 @@ def _truncate_rerank_pair(


# Минимальный скор реранкера для фильтрации низкокачественных чанков
# Chunk'и со скором ниже этого значения отсекаются из финальных результатов
# Chunk'и со скором ниже этого значения отсекаются из финальных результатов.
#
# ВАЖНО: порог задан для шкалы [0,1]. llama.cpp /v1/rerank отдаёт СЫРЫЕ логиты
# кросс-энкодера (диапазон ≈[-11,+11]), хотя endpoint позиционируется как
# Cohere-совместимый, где контракт обещает нормализацию в [0,1]
# (ggml-org/llama.cpp#9510 — собственный пример ggerganov: 5.97 и -11.03).
# Без нормализации порог 0.3 отсекает 70-97% выдачи, в т.ч. целевые файлы.
MIN_RERANK_SCORE = 0.3


def _sigmoid(x: float) -> float:
"""Логит -> вероятность [0,1], численно устойчиво для |x| > 700."""
if x >= 0.0:
return 1.0 / (1.0 + math.exp(-x))
e = math.exp(x)
return e / (1.0 + e)


class MultiProviderReranker(IReranker):
"""Реранкер на основе внешних LLM-провайдеров (Ollama / LM Studio).

Expand Down Expand Up @@ -631,6 +646,11 @@ async def rerank(
]
scores = await self._llama_cpp_rerank(query, passages)
if scores:
# llama.cpp отдаёт сырые логиты кросс-энкодера (≈[-11,+11]),
# а не нормализованный [0,1] по Cohere-контракту. Без
# приведения MIN_RERANK_SCORE отсекает 70-97% выдачи.
# Сортировка не меняется: сигмоида монотонна.
scores = [_sigmoid(s) for s in scores]
scored = [{"index": i, "score": s} for i, s in enumerate(scores)]
chunks = apply_scores(chunks, scored, top_n)
self.last_timing["reranker_ms"] = (_time.perf_counter() - t1) * 1000
Expand Down Expand Up @@ -664,11 +684,24 @@ async def rerank(

self.last_timing["total_ms"] = (_time.perf_counter() - t_start) * 1000

# Фильтр низкорелевантных чанков (мусор — JSON локали, битые fallback)
# Фильтр низкорелевантных чанков (мусор — JSON локали, битые fallback).
# На этом месте chunks уже отсортированы по reranker_score desc
# (apply_scores) и обрезаны до top_n.
_filtered = [
c for c in chunks[:top_n]
if c.get("reranker_score", 1.0) >= MIN_RERANK_SCORE
]
# Top-N recall floor (MAX_RERANKER_TOPN, default 0 = выключено):
# union прошедших порог с top-N по скору. Абсолютный порог хрупок на
# некалиброванных кросс-энкодерных скорах (P3: цель 0.271<0.3);
# floor гарантирует, что N лучших всегда доходят до выдачи.
_topn_keep = get_config().performance.reranker_topn_keep
if _topn_keep > 0 and len(_filtered) < min(_topn_keep, top_n):
_n = min(_topn_keep, top_n, len(chunks))
_keep_ids = {id(c) for c in _filtered} | {
id(c) for c in chunks[:_n]
}
_filtered = [c for c in chunks if id(c) in _keep_ids]
if _filtered:
return _filtered
return chunks[:top_n]
Expand Down
Loading
Loading