deeptutor-knowledge 1.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deeptutor/api/routers/knowledge.py +5225 -0
- deeptutor/api/routers/marginnote4.py +278 -0
- deeptutor/capabilities/ima/__init__.py +6 -0
- deeptutor/capabilities/ima/binding.py +118 -0
- deeptutor/capabilities/ima/capability.py +92 -0
- deeptutor/capabilities/ima/prompts/en/system.md +33 -0
- deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
- deeptutor/capabilities/ima/tools.py +476 -0
- deeptutor/capabilities/marginnote4/__init__.py +13 -0
- deeptutor/capabilities/marginnote4/binding.py +66 -0
- deeptutor/capabilities/marginnote4/capability.py +80 -0
- deeptutor/capabilities/marginnote4/models.py +173 -0
- deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
- deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
- deeptutor/capabilities/marginnote4/store.py +562 -0
- deeptutor/capabilities/marginnote4/tools.py +329 -0
- deeptutor/capabilities/obsidian/__init__.py +6 -0
- deeptutor/capabilities/obsidian/binding.py +75 -0
- deeptutor/capabilities/obsidian/capability.py +82 -0
- deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
- deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
- deeptutor/capabilities/obsidian/tools.py +399 -0
- deeptutor/capabilities/obsidian/vault.py +341 -0
- deeptutor/knowledge/__init__.py +28 -0
- deeptutor/knowledge/add_documents.py +734 -0
- deeptutor/knowledge/indexing_run.py +432 -0
- deeptutor/knowledge/initializer.py +448 -0
- deeptutor/knowledge/manager.py +2570 -0
- deeptutor/knowledge/module_service.py +29 -0
- deeptutor/knowledge/naming.py +40 -0
- deeptutor/knowledge/progress_events.py +45 -0
- deeptutor/knowledge/progress_tracker.py +355 -0
- deeptutor/knowledge/staging.py +69 -0
- deeptutor/services/config/knowledge_base_config.py +239 -0
- deeptutor/services/embedding/__init__.py +46 -0
- deeptutor/services/embedding/adapters/__init__.py +40 -0
- deeptutor/services/embedding/adapters/base.py +196 -0
- deeptutor/services/embedding/adapters/cohere.py +176 -0
- deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
- deeptutor/services/embedding/adapters/gemini.py +462 -0
- deeptutor/services/embedding/adapters/jina.py +161 -0
- deeptutor/services/embedding/adapters/ollama.py +145 -0
- deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
- deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
- deeptutor/services/embedding/client.py +323 -0
- deeptutor/services/embedding/config.py +114 -0
- deeptutor/services/embedding/request_options.py +46 -0
- deeptutor/services/embedding/validation.py +128 -0
- deeptutor/services/github_source/__init__.py +5 -0
- deeptutor/services/github_source/client.py +164 -0
- deeptutor/services/github_source/sync.py +384 -0
- deeptutor/services/github_source/sync_service.py +72 -0
- deeptutor/services/rag/__init__.py +21 -0
- deeptutor/services/rag/embedding_binding.py +331 -0
- deeptutor/services/rag/embedding_signature.py +63 -0
- deeptutor/services/rag/eval/__init__.py +92 -0
- deeptutor/services/rag/eval/dataset.py +244 -0
- deeptutor/services/rag/eval/diff.py +312 -0
- deeptutor/services/rag/eval/matching.py +218 -0
- deeptutor/services/rag/eval/metrics.py +213 -0
- deeptutor/services/rag/eval/report.py +111 -0
- deeptutor/services/rag/eval/runner.py +309 -0
- deeptutor/services/rag/factory.py +369 -0
- deeptutor/services/rag/file_routing.py +5 -0
- deeptutor/services/rag/index_probe.py +327 -0
- deeptutor/services/rag/index_versioning.py +387 -0
- deeptutor/services/rag/kb_paths.py +50 -0
- deeptutor/services/rag/linked_kb.py +237 -0
- deeptutor/services/rag/pipelines/__init__.py +7 -0
- deeptutor/services/rag/pipelines/base.py +41 -0
- deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
- deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
- deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
- deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
- deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
- deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
- deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
- deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
- deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
- deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
- deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
- deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
- deeptutor/services/rag/pipelines/ima/client.py +364 -0
- deeptutor/services/rag/pipelines/ima/config.py +140 -0
- deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
- deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
- deeptutor/services/rag/pipelines/ima/media.py +190 -0
- deeptutor/services/rag/pipelines/ima/models.py +348 -0
- deeptutor/services/rag/pipelines/ima/notes.py +240 -0
- deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
- deeptutor/services/rag/pipelines/ima/probe.py +105 -0
- deeptutor/services/rag/pipelines/ima/sources.py +105 -0
- deeptutor/services/rag/pipelines/ima/transport.py +130 -0
- deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
- deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
- deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
- deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
- deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
- deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
- deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
- deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
- deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
- deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
- deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
- deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
- deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
- deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
- deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
- deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
- deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
- deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
- deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
- deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
- deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
- deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
- deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
- deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
- deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
- deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
- deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
- deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
- deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
- deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
- deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
- deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
- deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
- deeptutor/services/rag/pipelines/modes.py +48 -0
- deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
- deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
- deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
- deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
- deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
- deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
- deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
- deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
- deeptutor/services/rag/pipelines/weknora/client.py +106 -0
- deeptutor/services/rag/pipelines/weknora/config.py +56 -0
- deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
- deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
- deeptutor/services/rag/preflight.py +274 -0
- deeptutor/services/rag/provider_binding.py +70 -0
- deeptutor/services/rag/query_rewrite.py +186 -0
- deeptutor/services/rag/service.py +379 -0
- deeptutor/services/rag/smart_retriever.py +81 -0
- deeptutor/services/rag/source_pages.py +292 -0
- deeptutor/services/rag/source_visuals.py +434 -0
- deeptutor/services/rag/visual_assets.py +430 -0
- deeptutor/services/rag/visual_coverage.py +165 -0
- deeptutor/services/web_source/__init__.py +1 -0
- deeptutor/services/web_source/bilingual.py +255 -0
- deeptutor/services/web_source/crawler.py +681 -0
- deeptutor/services/web_source/html_extractor.py +643 -0
- deeptutor/services/web_source/markdown.py +28 -0
- deeptutor/services/web_source/navigation.py +72 -0
- deeptutor/services/web_source/repository.py +640 -0
- deeptutor/services/web_source/robots.py +182 -0
- deeptutor/services/web_source/scheduler.py +336 -0
- deeptutor/services/web_source/snapshot_assets.py +171 -0
- deeptutor/services/web_source/sync.py +253 -0
- deeptutor/services/workspace/kb_move.py +446 -0
- deeptutor/tools/rag_tool.py +110 -0
- deeptutor_cli/kb.py +756 -0
- deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
- deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
- deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Gold-passage matching for retrieval evaluation.
|
|
2
|
+
|
|
3
|
+
An engine returns ranked chunks, not ground-truth identifiers, so relevance has
|
|
4
|
+
to be decided from text. A chunk counts as covering a gold passage when either
|
|
5
|
+
|
|
6
|
+
* one normalized text contains the other — the common cases are a chunk holding
|
|
7
|
+
the excerpt verbatim, and a *truncated* citation whose opening text sits inside
|
|
8
|
+
a longer gold passage (LlamaIndex and GraphRAG both cap ``content`` at 200
|
|
9
|
+
characters); or
|
|
10
|
+
* the two share enough tokens — this catches a paraphrased or synthesized
|
|
11
|
+
citation (LightRAG reports, GraphRAG community summaries) that never repeats
|
|
12
|
+
the excerpt word for word.
|
|
13
|
+
|
|
14
|
+
The token rule needs both a coverage ratio and a minimum shared-token count, so a
|
|
15
|
+
short generic phrase ("the model") cannot claim a long passage it merely happens
|
|
16
|
+
to appear in. Tokens are ASCII words plus individual CJK characters, which keeps
|
|
17
|
+
a Chinese excerpt comparable without a segmenter dependency.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections import Counter
|
|
23
|
+
from collections.abc import Mapping, Sequence
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
import re
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
#: Share of the shorter side's tokens that must overlap for an overlap match.
|
|
29
|
+
DEFAULT_MIN_RATIO = 0.5
|
|
30
|
+
#: Shared tokens required before an overlap match is accepted.
|
|
31
|
+
DEFAULT_MIN_TOKENS = 4
|
|
32
|
+
|
|
33
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
34
|
+
# ASCII words and digits, or one token per CJK / Kana / Hangul character.
|
|
35
|
+
_TOKEN = re.compile(
|
|
36
|
+
r"[0-9a-z]+"
|
|
37
|
+
r"|[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
#: Keys that hold a citation's text across the shipped engines.
|
|
41
|
+
SOURCE_TEXT_KEYS = ("content", "text", "snippet", "excerpt", "summary")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def normalize_text(value: str) -> str:
|
|
45
|
+
"""Lower-case ``value`` and collapse whitespace for containment checks."""
|
|
46
|
+
return _WHITESPACE.sub(" ", (value or "").strip().lower())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def tokenize(value: str) -> list[str]:
|
|
50
|
+
"""Split ``value`` into comparison tokens (ASCII words + CJK characters)."""
|
|
51
|
+
return _TOKEN.findall((value or "").lower())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def source_text(source: Any) -> str:
|
|
55
|
+
"""Return the text of one citation record, across the engines' shapes.
|
|
56
|
+
|
|
57
|
+
Every pipeline normalizes what it retrieved into ``content``, but a
|
|
58
|
+
hand-written record or a future engine may use another key; an unrecognized
|
|
59
|
+
or non-mapping record yields an empty string, which never matches.
|
|
60
|
+
"""
|
|
61
|
+
if not isinstance(source, Mapping):
|
|
62
|
+
return ""
|
|
63
|
+
for key in SOURCE_TEXT_KEYS:
|
|
64
|
+
value = source.get(key)
|
|
65
|
+
if isinstance(value, str) and value.strip():
|
|
66
|
+
return value
|
|
67
|
+
return ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class MatchPolicy:
|
|
72
|
+
"""Thresholds for treating a retrieved chunk as covering a gold passage."""
|
|
73
|
+
|
|
74
|
+
min_ratio: float = DEFAULT_MIN_RATIO
|
|
75
|
+
min_tokens: int = DEFAULT_MIN_TOKENS
|
|
76
|
+
|
|
77
|
+
def __post_init__(self) -> None:
|
|
78
|
+
"""Reject thresholds that would make matching either trivial or impossible."""
|
|
79
|
+
if not 0.0 < float(self.min_ratio) <= 1.0:
|
|
80
|
+
raise ValueError("min_ratio must be in (0, 1]")
|
|
81
|
+
if int(self.min_tokens) < 1:
|
|
82
|
+
raise ValueError("min_tokens must be >= 1")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class MatchResult:
|
|
87
|
+
"""Which gold passages a chunk covers, plus the strongest coverage ratio."""
|
|
88
|
+
|
|
89
|
+
indices: tuple[int, ...] = ()
|
|
90
|
+
ratio: float = 0.0
|
|
91
|
+
|
|
92
|
+
def __bool__(self) -> bool:
|
|
93
|
+
"""True when the chunk covers at least one gold passage."""
|
|
94
|
+
return bool(self.indices)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class GoldPassage:
|
|
99
|
+
"""A gold passage prepared once, then matched against every retrieved chunk."""
|
|
100
|
+
|
|
101
|
+
index: int
|
|
102
|
+
text: str
|
|
103
|
+
normalized: str
|
|
104
|
+
tokens: Counter[str]
|
|
105
|
+
token_count: int
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_text(cls, index: int, text: str) -> GoldPassage:
|
|
109
|
+
"""Tokenize one gold passage for repeated comparison."""
|
|
110
|
+
token_list = tokenize(text)
|
|
111
|
+
return cls(
|
|
112
|
+
index=index,
|
|
113
|
+
text=text,
|
|
114
|
+
normalized=normalize_text(text),
|
|
115
|
+
tokens=Counter(token_list),
|
|
116
|
+
token_count=len(token_list),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _overlap(
|
|
121
|
+
chunk_tokens: Counter[str],
|
|
122
|
+
chunk_count: int,
|
|
123
|
+
passage: GoldPassage,
|
|
124
|
+
) -> tuple[float, int]:
|
|
125
|
+
"""Return ``(ratio, shared)`` for one chunk/passage pair.
|
|
126
|
+
|
|
127
|
+
The ratio divides by the shorter side's token count, so a truncated citation
|
|
128
|
+
whose tokens are all inside the passage still scores 1.0.
|
|
129
|
+
"""
|
|
130
|
+
if chunk_count <= 0 or passage.token_count <= 0:
|
|
131
|
+
return 0.0, 0
|
|
132
|
+
shared = 0
|
|
133
|
+
for token, count in chunk_tokens.items():
|
|
134
|
+
passage_count = passage.tokens.get(token, 0)
|
|
135
|
+
if passage_count:
|
|
136
|
+
shared += min(count, passage_count)
|
|
137
|
+
shorter = min(chunk_count, passage.token_count)
|
|
138
|
+
return shared / shorter, shared
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class RelevanceMatcher:
|
|
142
|
+
"""Decide whether retrieved chunks cover one case's gold passages.
|
|
143
|
+
|
|
144
|
+
The matcher is built per case: it prepares each gold passage once and then
|
|
145
|
+
scores every ranked chunk against them.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
gold: The case's gold excerpts, in dataset order.
|
|
149
|
+
policy: Matching thresholds; defaults to :class:`MatchPolicy`.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(self, gold: Sequence[str], policy: MatchPolicy | None = None) -> None:
|
|
153
|
+
self.policy = policy or MatchPolicy()
|
|
154
|
+
self.gold: tuple[GoldPassage, ...] = tuple(
|
|
155
|
+
GoldPassage.from_text(index, passage)
|
|
156
|
+
for index, passage in enumerate(gold)
|
|
157
|
+
if isinstance(passage, str) and passage.strip()
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def gold_count(self) -> int:
|
|
162
|
+
"""Number of non-blank gold passages this matcher scored against."""
|
|
163
|
+
return len(self.gold)
|
|
164
|
+
|
|
165
|
+
def match(self, text: str) -> MatchResult:
|
|
166
|
+
"""Return the gold passages ``text`` covers and the best coverage ratio."""
|
|
167
|
+
normalized = normalize_text(text)
|
|
168
|
+
if not normalized or not self.gold:
|
|
169
|
+
return MatchResult()
|
|
170
|
+
|
|
171
|
+
chunk_tokens = Counter(tokenize(text))
|
|
172
|
+
chunk_count = sum(chunk_tokens.values())
|
|
173
|
+
|
|
174
|
+
indices: list[int] = []
|
|
175
|
+
best = 0.0
|
|
176
|
+
for passage in self.gold:
|
|
177
|
+
ratio = self._ratio(normalized, chunk_tokens, chunk_count, passage)
|
|
178
|
+
if ratio > 0.0:
|
|
179
|
+
indices.append(passage.index)
|
|
180
|
+
best = max(best, ratio)
|
|
181
|
+
return MatchResult(tuple(indices), round(best, 4))
|
|
182
|
+
|
|
183
|
+
def _ratio(
|
|
184
|
+
self,
|
|
185
|
+
normalized: str,
|
|
186
|
+
chunk_tokens: Counter[str],
|
|
187
|
+
chunk_count: int,
|
|
188
|
+
passage: GoldPassage,
|
|
189
|
+
) -> float:
|
|
190
|
+
"""Coverage ratio for one passage (0.0 when it is not a match)."""
|
|
191
|
+
if not passage.normalized:
|
|
192
|
+
return 0.0
|
|
193
|
+
if passage.normalized in normalized:
|
|
194
|
+
return 1.0
|
|
195
|
+
# A truncated citation is shorter than the passage it quotes, so an
|
|
196
|
+
# exact reverse containment is only meaningful once it carries enough
|
|
197
|
+
# tokens to be more than an incidental substring.
|
|
198
|
+
if chunk_count >= max(1, int(self.policy.min_tokens)) and normalized in passage.normalized:
|
|
199
|
+
return 1.0
|
|
200
|
+
|
|
201
|
+
ratio, shared = _overlap(chunk_tokens, chunk_count, passage)
|
|
202
|
+
if ratio < float(self.policy.min_ratio) or shared < int(self.policy.min_tokens):
|
|
203
|
+
return 0.0
|
|
204
|
+
return ratio
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
__all__ = [
|
|
208
|
+
"DEFAULT_MIN_RATIO",
|
|
209
|
+
"DEFAULT_MIN_TOKENS",
|
|
210
|
+
"GoldPassage",
|
|
211
|
+
"MatchPolicy",
|
|
212
|
+
"MatchResult",
|
|
213
|
+
"RelevanceMatcher",
|
|
214
|
+
"SOURCE_TEXT_KEYS",
|
|
215
|
+
"normalize_text",
|
|
216
|
+
"source_text",
|
|
217
|
+
"tokenize",
|
|
218
|
+
]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Rank-aware retrieval metrics for knowledge-base evaluation.
|
|
2
|
+
|
|
3
|
+
Every metric is computed at a rank cutoff ``k`` from the *hits* a case produced:
|
|
4
|
+
``hits[i]`` is the set of gold-passage indices the chunk at rank ``i + 1`` covers
|
|
5
|
+
(see :mod:`deeptutor.services.rag.eval.matching`). Working from a set per rank —
|
|
6
|
+
rather than a single relevant/irrelevant flag — is what lets Recall count each
|
|
7
|
+
gold passage once even when two chunks quote it, and lets MAP credit the rank at
|
|
8
|
+
which each *new* passage first appears.
|
|
9
|
+
|
|
10
|
+
No metric here calls a model: they are pure functions over rankings, so a score
|
|
11
|
+
is reproducible from a stored report and cannot drift with a judge's sampling.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Collection, Sequence
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
import math
|
|
19
|
+
|
|
20
|
+
#: A per-rank record of which gold passages that rank covered.
|
|
21
|
+
Hits = Sequence[Collection[int]]
|
|
22
|
+
|
|
23
|
+
#: QueryMetrics attributes in the order of :func:`metric_keys`.
|
|
24
|
+
_METRIC_ATTRS = ("recall", "precision", "ndcg", "mrr", "hit", "average_precision")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def metric_keys(k: int) -> tuple[str, ...]:
|
|
28
|
+
"""Report keys for a cutoff ``k``, in display order."""
|
|
29
|
+
_require_k(k)
|
|
30
|
+
return (f"recall@{k}", f"precision@{k}", f"ndcg@{k}", "mrr", f"hit@{k}", f"map@{k}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _require_k(k: int) -> int:
|
|
34
|
+
"""Validate a rank cutoff, returning it as an int."""
|
|
35
|
+
value = int(k)
|
|
36
|
+
if value < 1:
|
|
37
|
+
raise ValueError("k must be >= 1")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _window(hits: Hits, k: int | None) -> Sequence[Collection[int]]:
|
|
42
|
+
"""Truncate a ranking to the cutoff (``None`` keeps every retrieved rank)."""
|
|
43
|
+
if k is None:
|
|
44
|
+
return hits
|
|
45
|
+
return hits[: _require_k(k)]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def hit_flags(hits: Hits, k: int | None = None) -> list[bool]:
|
|
49
|
+
"""Whether each rank (up to ``k``) covered at least one gold passage."""
|
|
50
|
+
return [bool(matched) for matched in _window(hits, k)]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def recall_at_k(hits: Hits, gold_count: int, k: int) -> float:
|
|
54
|
+
"""Share of the case's gold passages retrieved within the top ``k``."""
|
|
55
|
+
cutoff = _require_k(k)
|
|
56
|
+
if gold_count <= 0:
|
|
57
|
+
return 0.0
|
|
58
|
+
found: set[int] = set()
|
|
59
|
+
for matched in hits[:cutoff]:
|
|
60
|
+
found.update(matched)
|
|
61
|
+
return min(1.0, len(found) / gold_count)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def precision_at_k(hits: Hits, k: int) -> float:
|
|
65
|
+
"""Share of the retrieved chunks within ``k`` that covered a gold passage.
|
|
66
|
+
|
|
67
|
+
The denominator is the number of chunks actually retrieved, so a short
|
|
68
|
+
knowledge base is not punished for returning fewer than ``k`` chunks.
|
|
69
|
+
"""
|
|
70
|
+
cutoff = _require_k(k)
|
|
71
|
+
window = hits[:cutoff]
|
|
72
|
+
if not window:
|
|
73
|
+
return 0.0
|
|
74
|
+
return sum(1 for matched in window if matched) / len(window)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def hit_at_k(hits: Hits, k: int) -> float:
|
|
78
|
+
"""``1.0`` when any gold passage reached the top ``k`` — the citation hit rate."""
|
|
79
|
+
return 1.0 if any(_window(hits, k)) else 0.0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def reciprocal_rank(hits: Hits, k: int | None = None) -> float:
|
|
83
|
+
"""Reciprocal rank of the first chunk that covered a gold passage (MRR@k)."""
|
|
84
|
+
for rank, matched in enumerate(_window(hits, k), start=1):
|
|
85
|
+
if matched:
|
|
86
|
+
return 1.0 / rank
|
|
87
|
+
return 0.0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def average_precision(hits: Hits, gold_count: int, k: int | None = None) -> float:
|
|
91
|
+
"""Mean of the precision measured each time a *new* gold passage appears."""
|
|
92
|
+
if gold_count <= 0:
|
|
93
|
+
return 0.0
|
|
94
|
+
seen: set[int] = set()
|
|
95
|
+
total = 0.0
|
|
96
|
+
for rank, matched in enumerate(_window(hits, k), start=1):
|
|
97
|
+
fresh = {index for index in matched if index not in seen}
|
|
98
|
+
if not fresh:
|
|
99
|
+
continue
|
|
100
|
+
seen.update(fresh)
|
|
101
|
+
total += len(seen) / rank
|
|
102
|
+
return min(1.0, total / gold_count)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _dcg(gains: Sequence[float]) -> float:
|
|
106
|
+
"""Discounted cumulative gain with the standard ``log2(rank + 1)`` discount."""
|
|
107
|
+
return sum(gain / math.log2(rank + 1) for rank, gain in enumerate(gains, start=1))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def ndcg_at_k(hits: Hits, gold_count: int, k: int) -> float:
|
|
111
|
+
"""Binary nDCG@k — whether the gold passages are ranked near the top.
|
|
112
|
+
|
|
113
|
+
The ideal ranking holds the case's gold passages (capped at ``k``) ahead of
|
|
114
|
+
everything else, so a case whose gold is all retrieved but buried below
|
|
115
|
+
irrelevant chunks scores below 1. Each rank contributes at most one gain,
|
|
116
|
+
and only when it introduces a gold passage not covered by an earlier rank.
|
|
117
|
+
Repeating the same passage cannot substitute for missing gold passages.
|
|
118
|
+
"""
|
|
119
|
+
cutoff = _require_k(k)
|
|
120
|
+
if gold_count <= 0:
|
|
121
|
+
return 0.0
|
|
122
|
+
seen: set[int] = set()
|
|
123
|
+
gains: list[float] = []
|
|
124
|
+
for matched in hits[:cutoff]:
|
|
125
|
+
gains.append(1.0 if set(matched) - seen else 0.0)
|
|
126
|
+
seen.update(matched)
|
|
127
|
+
ideal_dcg = _dcg([1.0] * min(gold_count, cutoff))
|
|
128
|
+
if ideal_dcg == 0:
|
|
129
|
+
return 0.0
|
|
130
|
+
return min(1.0, _dcg(gains) / ideal_dcg)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class QueryMetrics:
|
|
135
|
+
"""One case's retrieval scores, plus the counts a report should show."""
|
|
136
|
+
|
|
137
|
+
query_id: str
|
|
138
|
+
k: int
|
|
139
|
+
retrieved: int
|
|
140
|
+
relevant_retrieved: int
|
|
141
|
+
gold_count: int
|
|
142
|
+
recall: float
|
|
143
|
+
precision: float
|
|
144
|
+
ndcg: float
|
|
145
|
+
mrr: float
|
|
146
|
+
hit: float
|
|
147
|
+
average_precision: float
|
|
148
|
+
|
|
149
|
+
def values(self) -> dict[str, float]:
|
|
150
|
+
"""Metric values keyed the way reports display them."""
|
|
151
|
+
keys = metric_keys(self.k)
|
|
152
|
+
return dict(zip(keys, (getattr(self, name) for name in _METRIC_ATTRS)))
|
|
153
|
+
|
|
154
|
+
def to_dict(self) -> dict[str, float | int | str]:
|
|
155
|
+
"""Counts plus rounded metrics, ready for JSON output."""
|
|
156
|
+
return {
|
|
157
|
+
"query_id": self.query_id,
|
|
158
|
+
"k": self.k,
|
|
159
|
+
"retrieved": self.retrieved,
|
|
160
|
+
"relevant_retrieved": self.relevant_retrieved,
|
|
161
|
+
"gold_count": self.gold_count,
|
|
162
|
+
**{key: round(value, 4) for key, value in self.values().items()},
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def evaluate_hits(query_id: str, hits: Hits, gold_count: int, k: int) -> QueryMetrics:
|
|
167
|
+
"""Score one case's ranking at cutoff ``k``."""
|
|
168
|
+
cutoff = _require_k(k)
|
|
169
|
+
window = hits[:cutoff]
|
|
170
|
+
return QueryMetrics(
|
|
171
|
+
query_id=query_id,
|
|
172
|
+
k=cutoff,
|
|
173
|
+
retrieved=len(hits),
|
|
174
|
+
relevant_retrieved=sum(1 for matched in window if matched),
|
|
175
|
+
gold_count=max(0, int(gold_count)),
|
|
176
|
+
recall=recall_at_k(hits, gold_count, cutoff),
|
|
177
|
+
precision=precision_at_k(hits, cutoff),
|
|
178
|
+
ndcg=ndcg_at_k(hits, gold_count, cutoff),
|
|
179
|
+
mrr=reciprocal_rank(hits, cutoff),
|
|
180
|
+
hit=hit_at_k(hits, cutoff),
|
|
181
|
+
average_precision=average_precision(hits, gold_count, cutoff),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def aggregate_query_metrics(items: Sequence[QueryMetrics], k: int) -> dict[str, float | int]:
|
|
186
|
+
"""Macro-average the metrics of ``items`` at cutoff ``k``.
|
|
187
|
+
|
|
188
|
+
An empty list yields zeros rather than an error, so a report always has a
|
|
189
|
+
metrics block to print.
|
|
190
|
+
"""
|
|
191
|
+
keys = metric_keys(k)
|
|
192
|
+
averages = {key: 0.0 for key in keys}
|
|
193
|
+
if items:
|
|
194
|
+
for key, name in zip(keys, _METRIC_ATTRS):
|
|
195
|
+
total = sum(getattr(item, name) for item in items)
|
|
196
|
+
averages[key] = round(total / len(items), 4)
|
|
197
|
+
return {**averages, "queries": len(items)}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
__all__ = [
|
|
201
|
+
"Hits",
|
|
202
|
+
"QueryMetrics",
|
|
203
|
+
"aggregate_query_metrics",
|
|
204
|
+
"average_precision",
|
|
205
|
+
"evaluate_hits",
|
|
206
|
+
"hit_at_k",
|
|
207
|
+
"hit_flags",
|
|
208
|
+
"metric_keys",
|
|
209
|
+
"ndcg_at_k",
|
|
210
|
+
"precision_at_k",
|
|
211
|
+
"recall_at_k",
|
|
212
|
+
"reciprocal_rank",
|
|
213
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Report shape for a retrieval-evaluation run.
|
|
2
|
+
|
|
3
|
+
The report is plain data plus two renderings: :meth:`EvalReport.to_json` for a
|
|
4
|
+
stored artifact (diffable between engine or settings changes) and
|
|
5
|
+
:meth:`EvalReport.summary_lines` for a one-block CLI summary. Rich table building
|
|
6
|
+
stays in the CLI, so this module carries no rendering dependency.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .matching import MatchPolicy
|
|
16
|
+
from .metrics import aggregate_query_metrics, metric_keys
|
|
17
|
+
from .runner import SOURCE_MODE_CONTEXT, QueryEvaluation
|
|
18
|
+
|
|
19
|
+
#: Decimals used when a metric is printed.
|
|
20
|
+
METRIC_DECIMALS = 3
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def format_metric(value: float | int) -> str:
|
|
24
|
+
"""Format one metric for a table cell or a summary line."""
|
|
25
|
+
return f"{float(value):.{METRIC_DECIMALS}f}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class EvalReport:
|
|
30
|
+
"""Everything one evaluation run produced."""
|
|
31
|
+
|
|
32
|
+
dataset_name: str
|
|
33
|
+
kb_name: str
|
|
34
|
+
provider: str
|
|
35
|
+
k: int
|
|
36
|
+
mode: str | None
|
|
37
|
+
policy: MatchPolicy
|
|
38
|
+
evaluations: tuple[QueryEvaluation, ...]
|
|
39
|
+
duration_seconds: float = 0.0
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def scored(self) -> tuple[QueryEvaluation, ...]:
|
|
43
|
+
"""Cases that produced a ranking and were scored."""
|
|
44
|
+
return tuple(item for item in self.evaluations if not item.failed)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def failed(self) -> tuple[QueryEvaluation, ...]:
|
|
48
|
+
"""Cases whose search failed, excluded from the aggregate."""
|
|
49
|
+
return tuple(item for item in self.evaluations if item.failed)
|
|
50
|
+
|
|
51
|
+
def metric_columns(self) -> tuple[str, ...]:
|
|
52
|
+
"""Column headers for the per-case metrics table."""
|
|
53
|
+
return metric_keys(self.k)
|
|
54
|
+
|
|
55
|
+
def format_metrics(self, evaluation: QueryEvaluation) -> dict[str, str]:
|
|
56
|
+
"""Formatted metric cells for one case, keyed by :meth:`metric_columns`."""
|
|
57
|
+
values = evaluation.metrics.values()
|
|
58
|
+
return {key: format_metric(values[key]) for key in self.metric_columns()}
|
|
59
|
+
|
|
60
|
+
def aggregate(self) -> dict[str, float | int]:
|
|
61
|
+
"""Macro-averaged metrics over the scored cases, plus the case counts."""
|
|
62
|
+
summary = aggregate_query_metrics([item.metrics for item in self.scored], self.k)
|
|
63
|
+
summary["queries_scored"] = len(self.scored)
|
|
64
|
+
summary["queries_failed"] = len(self.failed)
|
|
65
|
+
return summary
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict[str, Any]:
|
|
68
|
+
"""Full serializable report, including per-case detail."""
|
|
69
|
+
return {
|
|
70
|
+
"dataset": self.dataset_name,
|
|
71
|
+
"kb": self.kb_name,
|
|
72
|
+
"provider": self.provider,
|
|
73
|
+
"k": self.k,
|
|
74
|
+
"mode": self.mode or "",
|
|
75
|
+
"duration_seconds": round(self.duration_seconds, 2),
|
|
76
|
+
"match_policy": {
|
|
77
|
+
"min_ratio": self.policy.min_ratio,
|
|
78
|
+
"min_tokens": self.policy.min_tokens,
|
|
79
|
+
},
|
|
80
|
+
"metrics": self.aggregate(),
|
|
81
|
+
"queries": [
|
|
82
|
+
{
|
|
83
|
+
"query": item.query,
|
|
84
|
+
"error": item.error,
|
|
85
|
+
"context_only": item.source_mode == SOURCE_MODE_CONTEXT,
|
|
86
|
+
**item.to_dict(),
|
|
87
|
+
}
|
|
88
|
+
for item in self.evaluations
|
|
89
|
+
],
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
def to_json(self, *, indent: int = 2) -> str:
|
|
93
|
+
"""Serialize the report for ``--save`` or ``--format json``."""
|
|
94
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
|
|
95
|
+
|
|
96
|
+
def summary_lines(self) -> list[str]:
|
|
97
|
+
"""The header lines a CLI prints under the per-case table."""
|
|
98
|
+
summary = self.aggregate()
|
|
99
|
+
lines = [
|
|
100
|
+
f"dataset: {self.dataset_name} kb: {self.kb_name} provider: {self.provider or 'unknown'}",
|
|
101
|
+
f"k: {self.k} mode: {self.mode or 'engine default'}",
|
|
102
|
+
f"cases: {len(self.evaluations)} scored: {len(self.scored)} failed: {len(self.failed)}",
|
|
103
|
+
]
|
|
104
|
+
metrics = " ".join(f"{key}={format_metric(summary[key])}" for key in self.metric_columns())
|
|
105
|
+
lines.append(f"metrics (mean over scored cases): {metrics}")
|
|
106
|
+
if self.failed:
|
|
107
|
+
lines.append("failed cases carry no score and are excluded from the means")
|
|
108
|
+
return lines
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = ["EvalReport", "format_metric", "METRIC_DECIMALS"]
|