mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-31 04:45:23 +08:00
216 lines
6.1 KiB
Python
216 lines
6.1 KiB
Python
"""Reranking helpers for retrieval results.
|
|
|
|
Supports Host-provided rerank models and the legacy LLM listwise reranker.
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
|
|
from langbot_plugin.api.entities.builtin.provider.message import Message
|
|
from components.observability.telemetry import hash_text
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Maximum characters per candidate passage sent to the LLM.
|
|
_PASSAGE_TRUNCATE = 300
|
|
|
|
RERANK_PROMPT = """\
|
|
Given a query and candidate passages, rank the passages by relevance to the query.
|
|
Return ONLY the passage numbers in order from most relevant to least relevant.
|
|
Format: comma-separated numbers, e.g. "3,1,0,2,4"
|
|
|
|
Query: {query}
|
|
|
|
Candidates:
|
|
{candidates}
|
|
|
|
Ranking:"""
|
|
|
|
|
|
def _extract_text(msg: Message) -> str:
|
|
"""Extract plain text from an LLM response Message."""
|
|
if isinstance(msg.content, str):
|
|
return msg.content.strip()
|
|
if isinstance(msg.content, list):
|
|
return "".join(e.text for e in msg.content if e.type == "text").strip()
|
|
return ""
|
|
|
|
|
|
def _parse_ranking(text: str, n: int) -> list[int] | None:
|
|
"""Parse a comma/space separated list of integers from LLM output.
|
|
|
|
Returns a deduplicated list of valid indices (0..n-1), or *None* if
|
|
parsing fails entirely (no valid indices found).
|
|
"""
|
|
nums = re.findall(r"\d+", text)
|
|
if not nums:
|
|
return None
|
|
|
|
seen: set[int] = set()
|
|
indices: list[int] = []
|
|
for s in nums:
|
|
idx = int(s)
|
|
if 0 <= idx < n and idx not in seen:
|
|
seen.add(idx)
|
|
indices.append(idx)
|
|
return indices if indices else None
|
|
|
|
|
|
def _candidate_text(res: dict) -> str:
|
|
"""Extract the display text sent to rerankers."""
|
|
return res.get("metadata", {}).get("text", "") or ""
|
|
|
|
|
|
def _text_log_ref(text: str | None) -> str:
|
|
"""Return a privacy-preserving text reference for logs."""
|
|
return f"hash={hash_text(text)}, length={len(text or '')}"
|
|
|
|
|
|
def _apply_ranking(
|
|
results: list[dict],
|
|
ranking: list[int],
|
|
top_k: int,
|
|
scores_by_index: dict[int, float] | None = None,
|
|
) -> list[dict]:
|
|
"""Apply a candidate index ranking to result dicts."""
|
|
if len(ranking) < len(results):
|
|
remaining = [i for i in range(len(results)) if i not in set(ranking)]
|
|
ranking.extend(remaining)
|
|
|
|
reranked = [results[i] for i in ranking[:top_k]]
|
|
|
|
# Rewrite distance so downstream sorting stays consistent with reranking.
|
|
for rank, res in enumerate(reranked):
|
|
res["distance"] = 0.01 * (rank + 1)
|
|
if scores_by_index and ranking[rank] in scores_by_index:
|
|
res["score"] = scores_by_index[ranking[rank]]
|
|
|
|
return reranked
|
|
|
|
|
|
async def model_rerank(
|
|
plugin,
|
|
rerank_model_uuid: str,
|
|
query: str,
|
|
results: list[dict],
|
|
top_k: int,
|
|
) -> list[dict]:
|
|
"""Rerank *results* using a Host rerank model.
|
|
|
|
The Host returns score entries with candidate indices. On failure or an
|
|
unusable response, retrieval falls back to the original order.
|
|
"""
|
|
if not results:
|
|
return results
|
|
|
|
n = len(results)
|
|
logger.info(
|
|
f"[Rerank] Model reranking {n} candidates "
|
|
f"for query_ref=({_text_log_ref(query)})"
|
|
)
|
|
documents = [_candidate_text(res) for res in results]
|
|
|
|
try:
|
|
scores = await plugin.invoke_rerank(
|
|
rerank_model_uuid,
|
|
query,
|
|
documents,
|
|
top_k=top_k,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"[Rerank] Model rerank call failed, falling back to original order: {e}"
|
|
)
|
|
return results[:top_k]
|
|
|
|
ranking: list[int] = []
|
|
scores_by_index: dict[int, float] = {}
|
|
seen: set[int] = set()
|
|
|
|
sorted_scores = sorted(
|
|
scores,
|
|
key=lambda item: item.get("relevance_score", 0),
|
|
reverse=True,
|
|
)
|
|
for item in sorted_scores:
|
|
try:
|
|
idx = int(item.get("index"))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if 0 <= idx < n and idx not in seen:
|
|
seen.add(idx)
|
|
ranking.append(idx)
|
|
score = item.get("relevance_score")
|
|
if isinstance(score, (int, float)):
|
|
scores_by_index[idx] = float(score)
|
|
|
|
if not ranking:
|
|
logger.warning(
|
|
"[Rerank] Model rerank returned no valid indices, "
|
|
"falling back to original order"
|
|
)
|
|
return results[:top_k]
|
|
|
|
reranked = _apply_ranking(results, ranking, top_k, scores_by_index)
|
|
logger.info(
|
|
f"[Rerank] Done: {n} candidates -> top {len(reranked)} "
|
|
f"(order: {ranking[:top_k]})"
|
|
)
|
|
return reranked
|
|
|
|
|
|
async def llm_rerank(
|
|
plugin,
|
|
llm_uuid: str,
|
|
query: str,
|
|
results: list[dict],
|
|
top_k: int,
|
|
) -> list[dict]:
|
|
"""Rerank *results* using an LLM and return the top *top_k* entries.
|
|
|
|
On any failure (LLM error, unparseable response) the function falls back
|
|
to returning ``results[:top_k]`` so that retrieval is never blocked.
|
|
"""
|
|
if not results:
|
|
return results
|
|
|
|
n = len(results)
|
|
logger.info(
|
|
f"[Rerank] LLM reranking {n} candidates "
|
|
f"for query_ref=({_text_log_ref(query)})"
|
|
)
|
|
|
|
# Build numbered candidate list
|
|
lines: list[str] = []
|
|
for i, res in enumerate(results):
|
|
text = _candidate_text(res)[:_PASSAGE_TRUNCATE]
|
|
lines.append(f"[{i}] {text}")
|
|
candidates_block = "\n".join(lines)
|
|
|
|
prompt = RERANK_PROMPT.format(query=query, candidates=candidates_block)
|
|
|
|
try:
|
|
resp = await plugin.invoke_llm(
|
|
llm_uuid, [Message(role="user", content=prompt)]
|
|
)
|
|
raw = _extract_text(resp)
|
|
logger.info(f"[Rerank] LLM response ref: {_text_log_ref(raw)}")
|
|
except Exception as e:
|
|
logger.warning(f"[Rerank] LLM call failed, falling back to original order: {e}")
|
|
return results[:top_k]
|
|
|
|
ranking = _parse_ranking(raw, n)
|
|
if ranking is None:
|
|
logger.warning(
|
|
"[Rerank] Failed to parse ranking from LLM response, "
|
|
"falling back to original order"
|
|
)
|
|
return results[:top_k]
|
|
|
|
reranked = _apply_ranking(results, ranking, top_k)
|
|
logger.info(
|
|
f"[Rerank] Done: {n} candidates -> top {len(reranked)} "
|
|
f"(order: {ranking[:top_k]})"
|
|
)
|
|
return reranked
|