Spaces:
Sleeping
Sleeping
File size: 6,986 Bytes
2e818da | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | """Retrieval-outcome classification and stage/candidate observation helpers.
Shared by the instrumented RAG call sites (ingestion, ``ChromaDBClient``,
``handlers._get_chunks``) so instrumentation logic stays out of the product
modules and out of ``handlers.py``. Per the plan's file-ownership table this
module owns "retrieval outcome classification and stage/candidate
observations".
It emits nothing itself: no span, metric, log, or file write happens here. It
only classifies exceptions into stable, low-cardinality categories and computes
content-free count/distribution attributes (never chunk text, never document
IDs as measures). Callers hand the returned dicts to
``Operation.set(...)`` / ``op.add_count(...)`` which re-sanitize before export.
"""
from __future__ import annotations
import statistics
from typing import Any
# Stable, low-cardinality retrieval-error categories. Callers pass the stage
# that failed; these are the vocabulary the "retrieval_error" logs/attributes
# use so a dashboard can group by cause without unbounded cardinality.
COLLECTION_UNAVAILABLE = "collection_unavailable"
COLLECTION_COUNT_FAILED = "collection_count_failed"
VECTOR_SEARCH_FAILED = "vector_search_failed"
EMBEDDING_FAILED = "embedding_failed"
RESULT_PREPARE_FAILED = "result_prepare_failed"
UNKNOWN_ERROR = "unknown_error"
def error_category(exc: BaseException) -> str:
"""A stable, content-free category for an exception (its class name).
Never includes the exception message, which can embed a filesystem path or
document text. The operation boundary's sanitizer bounds it further.
"""
return type(exc).__name__
def chunk_char_stats(chunks: list[str]) -> dict[str, int]:
"""Count + character-size distribution of a chunk list (no text).
Returns only integer counts/lengths -- min/median/max/total characters and
the chunk count -- so a dashboard can see chunk-size distribution without
any chunk content ever leaving the process.
"""
lengths = [len(c) for c in chunks]
if not lengths:
return {"chunk_count": 0}
return {
"chunk_count": len(lengths),
"chunk_chars_total": sum(lengths),
"chunk_chars_min": min(lengths),
"chunk_chars_median": int(statistics.median(lengths)),
"chunk_chars_max": max(lengths),
}
def candidate_stats(candidates: list[dict[str, Any]]) -> dict[str, int | float]:
"""Content-free retrieval-behavior counts from a candidate dict list.
Emits ``raw_candidate_count`` plus document/section *diversity* (distinct
counts only -- never the ids/labels themselves) and
``duplicate_candidate_ratio`` (a fraction, never chunk text), matching the
canonical "Retrieval-behavior measures" contract. Applies identically
regardless of which pipeline version produced ``candidates`` -- it is a
pure function of the list handed to it, so calling it on a naive top-5
and on an over-fetched-then-deduplicated top-5 uses the exact same
yardstick, which is what makes a v1/v2 comparison meaningful.
"""
docs = {str(c.get("document_id")) for c in candidates if c.get("document_id")}
sources = {str(c.get("source")) for c in candidates if c.get("source")}
return {
"raw_candidate_count": len(candidates),
"document_diversity": len(docs),
"section_diversity": len(sources),
"duplicate_candidate_ratio": duplicate_candidate_ratio(candidates),
}
# --- Near-duplicate detection -------------------------------------------------
#
# Deterministic and content-free *in what it emits*: these helpers read chunk
# text internally (this is product code operating on already-retrieved
# in-process candidates, not telemetry export -- see module docstring), but
# every value that ever reaches `Operation.set(...)`/a span/a metric is a
# count or a ratio, never the text itself.
#
# Similarity definition: whitespace/case-normalized token-set Jaccard
# similarity. Chosen over an embedding-distance or edit-distance definition
# because it directly matches the failure mode Task 7 diagnosed from real
# SigNoz data -- the chunker's 64-character overlap produces adjacent chunks
# that share most of their words verbatim (near-identical, not paraphrased),
# and Jaccard on tokens is deterministic, has no model/network dependency,
# and is trivially the same computation used to both *measure* duplication
# (`duplicate_candidate_ratio`) and *act on it* (the dedup mechanism in
# `ChromaDBClient.query_observed`) -- one shared definition of "duplicate",
# not two that could quietly drift apart.
NEAR_DUPLICATE_JACCARD_THRESHOLD = 0.8
def _normalize_text(text: str) -> str:
return " ".join(text.lower().split())
def _token_set(text: str) -> set[str]:
return set(_normalize_text(text).split())
def _jaccard(a: set[str], b: set[str]) -> float:
if not a and not b:
return 1.0
if not a or not b:
return 0.0
union = len(a | b)
return (len(a & b) / union) if union else 0.0
def is_near_duplicate(
text_a: str, text_b: str, *, threshold: float = NEAR_DUPLICATE_JACCARD_THRESHOLD
) -> bool:
"""True if two chunk texts are exact or near-duplicate of each other.
Exact matches always count (fast path, and correct even for empty
strings, which have Jaccard similarity 1.0 by the ``_jaccard`` convention
above but are a degenerate case worth short-circuiting explicitly).
"""
if text_a == text_b:
return True
return _jaccard(_token_set(text_a), _token_set(text_b)) >= threshold
def find_duplicate_indices(candidates: list[dict[str, Any]]) -> set[int]:
"""Indices of candidates that are a near-duplicate of some *earlier*
candidate in the same list.
First-occurrence-wins: within a group of mutually near-duplicate
candidates, the earliest (highest-ranked) one is kept and every later one
in that group is flagged. Order-dependent by design -- both the
diversity/duplication *measurement* (`candidate_stats`) and the dedup
*mechanism* (`ChromaDBClient.query_observed`) receive candidates in
Chroma's own relevance-ranked order, so "keep the earliest" means "keep
the most relevant member of each near-duplicate group."
"""
kept_texts: list[str] = []
dup_indices: set[int] = set()
for i, c in enumerate(candidates):
text = str(c.get("text", ""))
if any(is_near_duplicate(text, kept) for kept in kept_texts):
dup_indices.add(i)
else:
kept_texts.append(text)
return dup_indices
def duplicate_candidate_ratio(candidates: list[dict[str, Any]]) -> float:
"""Fraction of ``candidates`` that are a near-duplicate of an earlier one.
A pure number (0.0-1.0), never chunk text -- safe to attach to a span or
metric under the sanitization rules this module's docstring describes.
"""
if not candidates:
return 0.0
return len(find_duplicate_indices(candidates)) / len(candidates)
|