ragrefine 0.2.1__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.
- ragrefine/__init__.py +14 -0
- ragrefine/_channels.py +16 -0
- ragrefine/config.py +27 -0
- ragrefine/errors.py +21 -0
- ragrefine/integrations/__init__.py +1 -0
- ragrefine/integrations/haystack/__init__.py +33 -0
- ragrefine/integrations/haystack/adapter.py +72 -0
- ragrefine/integrations/haystack/component.py +150 -0
- ragrefine/integrations/haystack/fixture.py +96 -0
- ragrefine/integrations/haystack/pipeline.py +104 -0
- ragrefine/integrations/haystack/prompt.py +17 -0
- ragrefine/models.py +52 -0
- ragrefine/py.typed +0 -0
- ragrefine/query/__init__.py +18 -0
- ragrefine/query/normalize.py +34 -0
- ragrefine/query/patterns.py +132 -0
- ragrefine/ranking/__init__.py +24 -0
- ragrefine/ranking/lexical.py +97 -0
- ragrefine/ranking/patterns.py +153 -0
- ragrefine/ranking/rrf.py +87 -0
- ragrefine/ranking/types.py +25 -0
- ragrefine/refiner.py +516 -0
- ragrefine/rerank/__init__.py +6 -0
- ragrefine/rerank/base.py +30 -0
- ragrefine/rerank/sentence_transformers.py +133 -0
- ragrefine/results.py +20 -0
- ragrefine/selection/__init__.py +27 -0
- ragrefine/selection/deduplicate.py +142 -0
- ragrefine/selection/selector.py +126 -0
- ragrefine/selection/tokens.py +10 -0
- ragrefine/tracing/__init__.py +5 -0
- ragrefine/tracing/models.py +22 -0
- ragrefine-0.2.1.dist-info/METADATA +183 -0
- ragrefine-0.2.1.dist-info/RECORD +36 -0
- ragrefine-0.2.1.dist-info/WHEEL +4 -0
- ragrefine-0.2.1.dist-info/licenses/LICENSE +21 -0
ragrefine/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Retriever-agnostic post-retrieval refinement for RAG candidate sets."""
|
|
2
|
+
|
|
3
|
+
from ragrefine.config import RefinerConfig
|
|
4
|
+
from ragrefine.models import Candidate, CandidateSet
|
|
5
|
+
from ragrefine.refiner import Refiner
|
|
6
|
+
from ragrefine.results import RefinementResult
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Candidate",
|
|
10
|
+
"CandidateSet",
|
|
11
|
+
"RefinerConfig",
|
|
12
|
+
"RefinementResult",
|
|
13
|
+
"Refiner",
|
|
14
|
+
]
|
ragrefine/_channels.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Private channel execution result used only by :mod:`ragrefine.refiner`."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from ragrefine.models import Candidate
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class _ChannelResult:
|
|
11
|
+
"""One successful channel execution over the unchanged input candidate pool."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
ranking: tuple[Candidate, ...]
|
|
15
|
+
evidence: Mapping[str, Mapping[str, object]]
|
|
16
|
+
metadata: Mapping[str, object] = field(default_factory=dict)
|
ragrefine/config.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Public configuration for deterministic refinement channels."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class ChannelConfig:
|
|
8
|
+
"""Whether to run one channel, require success, and its fusion weight."""
|
|
9
|
+
|
|
10
|
+
enabled: bool = False
|
|
11
|
+
required: bool = True
|
|
12
|
+
weight: float = 1.0
|
|
13
|
+
|
|
14
|
+
def __post_init__(self) -> None:
|
|
15
|
+
if self.weight < 0:
|
|
16
|
+
raise ValueError("channel weight must be non-negative")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class RefinerConfig:
|
|
21
|
+
"""Explicit independent channel configuration for B0 through B3 modes."""
|
|
22
|
+
|
|
23
|
+
original: ChannelConfig = field(default_factory=lambda: ChannelConfig(enabled=True))
|
|
24
|
+
neural: ChannelConfig = field(default_factory=ChannelConfig)
|
|
25
|
+
lexical: ChannelConfig = field(default_factory=ChannelConfig)
|
|
26
|
+
pattern: ChannelConfig = field(default_factory=ChannelConfig)
|
|
27
|
+
fusion_enabled: bool = False
|
ragrefine/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Typed package exceptions for refinement failures."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RagRefineError(Exception):
|
|
5
|
+
"""Base exception for explicit ragrefine failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RerankerError(RagRefineError):
|
|
9
|
+
"""Raised when neural reranking cannot complete safely."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PatternError(RagRefineError):
|
|
13
|
+
"""Raised when configured pattern processing is invalid or unsafe."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ChannelExecutionError(RagRefineError):
|
|
17
|
+
"""Raised when a required ranking channel cannot complete."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MultipleActiveChannelsError(RagRefineError):
|
|
21
|
+
"""Raised when independent rankings need fusion but it is disabled."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional framework integrations for ragrefine."""
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Optional Haystack integration for ragrefine.
|
|
2
|
+
|
|
3
|
+
Installed with ``ragrefine[haystack]``. Provides the document adapter,
|
|
4
|
+
``RagRefineComponent`` with serializable refiner profiles, the prompt builder,
|
|
5
|
+
a frozen retrieval fixture, and a ready-made RAG pipeline builder.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ragrefine.integrations.haystack.adapter import HaystackDocumentAdapter
|
|
9
|
+
from ragrefine.integrations.haystack.component import (
|
|
10
|
+
RagRefineComponent,
|
|
11
|
+
refiner_for_profile,
|
|
12
|
+
register_refiner_factory,
|
|
13
|
+
)
|
|
14
|
+
from ragrefine.integrations.haystack.fixture import FrozenHaystackFixture
|
|
15
|
+
from ragrefine.integrations.haystack.pipeline import (
|
|
16
|
+
ChatPromptAdapter,
|
|
17
|
+
build_rag_pipeline,
|
|
18
|
+
demo_documents,
|
|
19
|
+
)
|
|
20
|
+
from ragrefine.integrations.haystack.prompt import PROMPT_TEMPLATE, prompt_builder
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ChatPromptAdapter",
|
|
24
|
+
"FrozenHaystackFixture",
|
|
25
|
+
"HaystackDocumentAdapter",
|
|
26
|
+
"PROMPT_TEMPLATE",
|
|
27
|
+
"RagRefineComponent",
|
|
28
|
+
"build_rag_pipeline",
|
|
29
|
+
"demo_documents",
|
|
30
|
+
"prompt_builder",
|
|
31
|
+
"refiner_for_profile",
|
|
32
|
+
"register_refiner_factory",
|
|
33
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Optional Haystack ``Document`` conversion without core-package coupling."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Sequence
|
|
4
|
+
|
|
5
|
+
from haystack import Document
|
|
6
|
+
|
|
7
|
+
from ragrefine.models import Candidate, CandidateSet, RefinedCandidate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HaystackDocumentAdapter:
|
|
11
|
+
"""Adapt one ordered Haystack result set while retaining document identity.
|
|
12
|
+
|
|
13
|
+
The adapter owns no ranking logic. It converts documents to immutable core
|
|
14
|
+
candidates, then maps selected/refined candidates back to the exact original
|
|
15
|
+
``Document`` objects supplied at construction.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self, documents: Sequence[Document], *, name: str = "haystack"
|
|
20
|
+
) -> None:
|
|
21
|
+
if not name:
|
|
22
|
+
raise ValueError("candidate-set name must be non-empty")
|
|
23
|
+
self._documents = tuple(documents)
|
|
24
|
+
self._by_id: dict[str, Document] = {}
|
|
25
|
+
for document in self._documents:
|
|
26
|
+
if not isinstance(document, Document):
|
|
27
|
+
raise TypeError("documents must contain Haystack Document instances")
|
|
28
|
+
document_id = str(document.id)
|
|
29
|
+
if not document_id:
|
|
30
|
+
raise ValueError("Haystack Document IDs must be non-empty")
|
|
31
|
+
if document_id in self._by_id:
|
|
32
|
+
raise ValueError(f"duplicate Haystack Document ID: {document_id}")
|
|
33
|
+
self._by_id[document_id] = document
|
|
34
|
+
self._name = name
|
|
35
|
+
|
|
36
|
+
def candidate_set(self) -> CandidateSet:
|
|
37
|
+
"""Return the ordered, provenance-preserving core candidate set."""
|
|
38
|
+
return CandidateSet(
|
|
39
|
+
name=self._name,
|
|
40
|
+
candidates=tuple(
|
|
41
|
+
Candidate(
|
|
42
|
+
id=str(document.id),
|
|
43
|
+
text=document.content or "",
|
|
44
|
+
metadata=dict(document.meta),
|
|
45
|
+
retrieval_score=document.score,
|
|
46
|
+
retrieval_rank=index,
|
|
47
|
+
)
|
|
48
|
+
for index, document in enumerate(self._documents, start=1)
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def documents_for(
|
|
53
|
+
self, candidates: Iterable[RefinedCandidate | Candidate]
|
|
54
|
+
) -> tuple[Document, ...]:
|
|
55
|
+
"""Return the original documents for refined/selected core candidates."""
|
|
56
|
+
resolved: list[Document] = []
|
|
57
|
+
for item in candidates:
|
|
58
|
+
candidate = item.candidate if isinstance(item, RefinedCandidate) else item
|
|
59
|
+
if not isinstance(candidate, Candidate):
|
|
60
|
+
raise TypeError("candidates must contain Candidate or RefinedCandidate")
|
|
61
|
+
try:
|
|
62
|
+
document = self._by_id[candidate.id]
|
|
63
|
+
except KeyError as error:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"candidate {candidate.id!r} was not created by this adapter"
|
|
66
|
+
) from error
|
|
67
|
+
if document.content != candidate.text:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"candidate {candidate.id!r} text does not match source"
|
|
70
|
+
)
|
|
71
|
+
resolved.append(document)
|
|
72
|
+
return tuple(resolved)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Native optional Haystack component for post-retrieval refinement."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from haystack import Document, component, default_to_dict
|
|
8
|
+
|
|
9
|
+
from ragrefine import Refiner, RefinerConfig
|
|
10
|
+
from ragrefine.config import ChannelConfig
|
|
11
|
+
from ragrefine.integrations.haystack.adapter import HaystackDocumentAdapter
|
|
12
|
+
from ragrefine.ranking.lexical import LexicalRanker
|
|
13
|
+
|
|
14
|
+
_REFINER_FACTORIES: dict[str, Callable[[], Refiner]] = {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register_refiner_factory(name: str, factory: Callable[[], Refiner]) -> None:
|
|
18
|
+
"""Register a named refiner profile for serializable components.
|
|
19
|
+
|
|
20
|
+
Built-in profiles are ``b0`` (original retrieval order) and
|
|
21
|
+
``b2-l-lexical`` (the retained lightweight lexical profile). A custom
|
|
22
|
+
profile must be registered before a component using it is serialized, so
|
|
23
|
+
Haystack pipeline YAML can rebuild the exact refiner on load.
|
|
24
|
+
"""
|
|
25
|
+
if not isinstance(name, str) or not name:
|
|
26
|
+
raise ValueError("refiner profile name must be a non-empty string")
|
|
27
|
+
if not callable(factory):
|
|
28
|
+
raise TypeError("refiner factory must be callable")
|
|
29
|
+
if name in _REFINER_FACTORIES:
|
|
30
|
+
raise ValueError(f"refiner profile already registered: {name!r}")
|
|
31
|
+
_REFINER_FACTORIES[name] = factory
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def refiner_for_profile(name: str) -> Refiner:
|
|
35
|
+
"""Build the refiner for one registered profile name."""
|
|
36
|
+
try:
|
|
37
|
+
return _REFINER_FACTORIES[name]()
|
|
38
|
+
except KeyError as error:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"unknown refiner profile {name!r}; "
|
|
41
|
+
f"registered: {sorted(_REFINER_FACTORIES)}"
|
|
42
|
+
) from error
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _b0_refiner() -> Refiner:
|
|
46
|
+
"""Original-order (B0) profile: refinement leaves the pool order unchanged."""
|
|
47
|
+
return Refiner()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _b2_l_lexical_refiner() -> Refiner:
|
|
51
|
+
"""Retained B2-L profile: stdlib lexical-coverage ranking only."""
|
|
52
|
+
return Refiner(
|
|
53
|
+
lexical_ranker=LexicalRanker(),
|
|
54
|
+
config=RefinerConfig(
|
|
55
|
+
original=ChannelConfig(enabled=False),
|
|
56
|
+
lexical=ChannelConfig(enabled=True),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
register_refiner_factory("b0", _b0_refiner)
|
|
62
|
+
register_refiner_factory("b2-l-lexical", _b2_l_lexical_refiner)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@component
|
|
66
|
+
class RagRefineComponent:
|
|
67
|
+
"""Run a configured core refiner over one Haystack document result set.
|
|
68
|
+
|
|
69
|
+
The component intentionally delegates all ranking and selection semantics to
|
|
70
|
+
``Refiner``. It returns the original document objects in the selected final
|
|
71
|
+
order plus a JSON-compatible trace. Built-in serializable profiles are
|
|
72
|
+
``b0`` and ``b2-l-lexical``; custom refiners need a registered factory.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
refiner: Refiner | None = None,
|
|
79
|
+
candidate_set_name: str = "haystack",
|
|
80
|
+
top_k: int = 5,
|
|
81
|
+
max_tokens: int | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
if top_k < 0:
|
|
84
|
+
raise ValueError("top_k must be non-negative")
|
|
85
|
+
if not candidate_set_name:
|
|
86
|
+
raise ValueError("candidate_set_name must be non-empty")
|
|
87
|
+
self._refiner = refiner or Refiner()
|
|
88
|
+
self._candidate_set_name = candidate_set_name
|
|
89
|
+
self._top_k = top_k
|
|
90
|
+
self._max_tokens = max_tokens
|
|
91
|
+
|
|
92
|
+
@component.output_types(documents=list[Document], trace=dict[str, Any])
|
|
93
|
+
def run(self, query: str, documents: list[Document]) -> dict[str, object]:
|
|
94
|
+
"""Refine retrieved documents, preserving final core ordering exactly."""
|
|
95
|
+
if not isinstance(query, str):
|
|
96
|
+
raise TypeError("query must be a string")
|
|
97
|
+
adapter = HaystackDocumentAdapter(documents, name=self._candidate_set_name)
|
|
98
|
+
result = self._refiner.refine(
|
|
99
|
+
query,
|
|
100
|
+
adapter.candidate_set(),
|
|
101
|
+
top_k=self._top_k,
|
|
102
|
+
max_tokens=self._max_tokens,
|
|
103
|
+
)
|
|
104
|
+
return {
|
|
105
|
+
"documents": list(adapter.documents_for(result.candidates)),
|
|
106
|
+
"trace": asdict(result.trace),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
def to_dict(self) -> dict[str, Any]:
|
|
110
|
+
"""Serialize the component with its named refiner profile."""
|
|
111
|
+
return default_to_dict(
|
|
112
|
+
self,
|
|
113
|
+
candidate_set_name=self._candidate_set_name,
|
|
114
|
+
top_k=self._top_k,
|
|
115
|
+
max_tokens=self._max_tokens,
|
|
116
|
+
refiner_profile=self._profile_name(),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def from_dict(cls, data: dict[str, Any]) -> "RagRefineComponent":
|
|
121
|
+
"""Recreate a component from its serialized profile and settings."""
|
|
122
|
+
init_parameters = dict(data.get("init_parameters", {}))
|
|
123
|
+
profile = init_parameters.pop("refiner_profile", "b0")
|
|
124
|
+
return cls(refiner=refiner_for_profile(profile), **init_parameters)
|
|
125
|
+
|
|
126
|
+
def _profile_name(self) -> str:
|
|
127
|
+
for name, factory in _REFINER_FACTORIES.items():
|
|
128
|
+
try:
|
|
129
|
+
candidate = factory()
|
|
130
|
+
except Exception:
|
|
131
|
+
continue
|
|
132
|
+
if self._equivalent(candidate):
|
|
133
|
+
return name
|
|
134
|
+
raise ValueError(
|
|
135
|
+
"serialization requires a registered refiner profile; register one "
|
|
136
|
+
"with register_refiner_factory(...) "
|
|
137
|
+
f"(registered: {sorted(_REFINER_FACTORIES)})"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def _equivalent(self, other: "Refiner") -> bool:
|
|
141
|
+
# SLF001 - serialization boundary; compares the exact ranking wiring.
|
|
142
|
+
return (
|
|
143
|
+
self._refiner._config == other._config
|
|
144
|
+
and type(self._refiner._reranker) is type(other._reranker)
|
|
145
|
+
and type(self._refiner._lexical_ranker) is type(other._lexical_ranker)
|
|
146
|
+
and type(self._refiner._pattern_ranker) is type(other._pattern_ranker)
|
|
147
|
+
and type(self._refiner._fusion) is type(other._fusion)
|
|
148
|
+
and type(self._refiner._deduplicator) is type(other._deduplicator)
|
|
149
|
+
and type(self._refiner._selector) is type(other._selector)
|
|
150
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Frozen, reproducible Haystack retrieval fixture independent of ragrefine."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from haystack import Document
|
|
10
|
+
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
|
|
11
|
+
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
|
12
|
+
|
|
13
|
+
FROZEN_CORPUS = (
|
|
14
|
+
{
|
|
15
|
+
"id": "python-311",
|
|
16
|
+
"content": "Python 3.11 adds exception groups.",
|
|
17
|
+
"meta": {"topic": "python"},
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "python-312",
|
|
21
|
+
"content": "Python 3.12 improves the f-string parser.",
|
|
22
|
+
"meta": {"topic": "python"},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "haystack",
|
|
26
|
+
"content": "Haystack pipelines connect document retrieval components.",
|
|
27
|
+
"meta": {"topic": "rag"},
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
FROZEN_QUERIES = {
|
|
31
|
+
"python": "Which Python version improves f-string parsing?",
|
|
32
|
+
"rag": "How do Haystack pipelines retrieve documents?",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class RetrievalIdentity:
|
|
38
|
+
"""Persistable identity for the frozen retrieval corpus and configuration."""
|
|
39
|
+
|
|
40
|
+
corpus_sha256: str
|
|
41
|
+
queries_sha256: str
|
|
42
|
+
retriever: str
|
|
43
|
+
document_store: str
|
|
44
|
+
top_n: int
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FrozenHaystackFixture:
|
|
48
|
+
"""Build one real deterministic BM25 document-store retrieval fixture."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, *, top_n: int = 2) -> None:
|
|
51
|
+
if top_n < 1:
|
|
52
|
+
raise ValueError("top_n must be positive")
|
|
53
|
+
self._top_n = top_n
|
|
54
|
+
self._store = InMemoryDocumentStore(bm25_algorithm="BM25L")
|
|
55
|
+
self._store.write_documents(
|
|
56
|
+
[
|
|
57
|
+
Document(
|
|
58
|
+
id=str(row["id"]),
|
|
59
|
+
content=str(row["content"]),
|
|
60
|
+
meta=dict(cast(Mapping[str, Any], row["meta"])),
|
|
61
|
+
)
|
|
62
|
+
for row in FROZEN_CORPUS
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
self._retriever = InMemoryBM25Retriever(document_store=self._store)
|
|
66
|
+
|
|
67
|
+
def identity(self) -> RetrievalIdentity:
|
|
68
|
+
"""Return frozen corpus/query/retriever/index identity for persistence."""
|
|
69
|
+
return RetrievalIdentity(
|
|
70
|
+
corpus_sha256=_checksum(FROZEN_CORPUS),
|
|
71
|
+
queries_sha256=_checksum(FROZEN_QUERIES),
|
|
72
|
+
retriever="haystack.InMemoryBM25Retriever/BM25L",
|
|
73
|
+
document_store="haystack.InMemoryDocumentStore",
|
|
74
|
+
top_n=self._top_n,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def retrieve(self, query: str) -> tuple[Document, ...]:
|
|
78
|
+
"""Return the single frozen pool to reuse for every comparison branch."""
|
|
79
|
+
if not isinstance(query, str) or not query:
|
|
80
|
+
raise ValueError("query must be a non-empty string")
|
|
81
|
+
documents = self._retriever.run(query=query, top_k=self._top_n)["documents"]
|
|
82
|
+
if any(document.score is None for document in documents):
|
|
83
|
+
raise RuntimeError("Haystack retriever returned a document without a score")
|
|
84
|
+
return tuple(documents)
|
|
85
|
+
|
|
86
|
+
def frozen_query(self, name: str) -> str:
|
|
87
|
+
"""Return one named immutable fixture query."""
|
|
88
|
+
try:
|
|
89
|
+
return FROZEN_QUERIES[name]
|
|
90
|
+
except KeyError as error:
|
|
91
|
+
raise ValueError(f"unknown frozen query: {name}") from error
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _checksum(value: object) -> str:
|
|
95
|
+
encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
|
96
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Runnable Haystack RAG pipeline wiring for the ragrefine integration.
|
|
2
|
+
|
|
3
|
+
``build_rag_pipeline`` wires the frozen BM25 retriever, ``RagRefineComponent``
|
|
4
|
+
with a registered refiner profile, the fixed prompt builder, and an optional
|
|
5
|
+
chat generator:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
InMemoryBM25Retriever -> RagRefineComponent -> PromptBuilder -> ChatPromptAdapter
|
|
9
|
+
-> OpenAIChatGenerator
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The generator is built for DeepSeek's OpenAI-compatible endpoint from
|
|
13
|
+
``DEEPSEEK_API_KEY``/``DEEPSEEK_MODEL`` when configured, or omitted entirely
|
|
14
|
+
(prompt-only pipeline). Refinement is post-retrieval only: it never changes
|
|
15
|
+
retrieval, indexing, or generation semantics.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from typing import Any, cast
|
|
21
|
+
|
|
22
|
+
from haystack import Document, Pipeline, component
|
|
23
|
+
from haystack.components.generators.chat import OpenAIChatGenerator
|
|
24
|
+
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
|
|
25
|
+
from haystack.core.component.component import Component
|
|
26
|
+
from haystack.dataclasses import ChatMessage
|
|
27
|
+
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
|
28
|
+
from haystack.utils import Secret
|
|
29
|
+
|
|
30
|
+
from ragrefine.integrations.haystack.component import (
|
|
31
|
+
RagRefineComponent,
|
|
32
|
+
refiner_for_profile,
|
|
33
|
+
)
|
|
34
|
+
from ragrefine.integrations.haystack.fixture import FROZEN_CORPUS
|
|
35
|
+
from ragrefine.integrations.haystack.prompt import prompt_builder
|
|
36
|
+
|
|
37
|
+
DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@component
|
|
41
|
+
class ChatPromptAdapter:
|
|
42
|
+
"""Turn a PromptBuilder ``prompt`` string into a chat ``messages`` list."""
|
|
43
|
+
|
|
44
|
+
@component.output_types(messages=list[ChatMessage])
|
|
45
|
+
def run(self, prompt: str) -> dict[str, Any]:
|
|
46
|
+
"""Wrap one prompt string as a user chat message."""
|
|
47
|
+
return {"messages": [ChatMessage.from_user(prompt)]}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def demo_documents() -> list[Document]:
|
|
51
|
+
"""Return the frozen retrieval corpus as documents for an example store."""
|
|
52
|
+
return [
|
|
53
|
+
Document(
|
|
54
|
+
id=str(row["id"]),
|
|
55
|
+
content=str(row["content"]),
|
|
56
|
+
meta=dict(cast(Mapping[str, Any], row["meta"])),
|
|
57
|
+
)
|
|
58
|
+
for row in FROZEN_CORPUS
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_rag_pipeline(
|
|
63
|
+
document_store: InMemoryDocumentStore,
|
|
64
|
+
*,
|
|
65
|
+
top_k: int = 3,
|
|
66
|
+
profile: str = "b2-l-lexical",
|
|
67
|
+
api_key: str | None = None,
|
|
68
|
+
model: str | None = None,
|
|
69
|
+
generator: Component | None = None,
|
|
70
|
+
) -> Pipeline:
|
|
71
|
+
"""Return a retriever -> refine -> prompt -> chat-generator Haystack pipeline.
|
|
72
|
+
|
|
73
|
+
``profile`` is any registered refiner profile (built-in ``b0`` and
|
|
74
|
+
``b2-l-lexical``). When ``generator`` is supplied it is used as the final
|
|
75
|
+
chat generator; otherwise one is built for DeepSeek's OpenAI-compatible
|
|
76
|
+
endpoint when ``api_key`` is available, and the pipeline stops after the
|
|
77
|
+
prompt builder when it is not.
|
|
78
|
+
"""
|
|
79
|
+
pipeline = Pipeline()
|
|
80
|
+
pipeline.add_component(
|
|
81
|
+
"retriever", InMemoryBM25Retriever(document_store=document_store)
|
|
82
|
+
)
|
|
83
|
+
pipeline.add_component(
|
|
84
|
+
"refine",
|
|
85
|
+
RagRefineComponent(top_k=top_k, refiner=refiner_for_profile(profile)),
|
|
86
|
+
)
|
|
87
|
+
pipeline.add_component("prompt", prompt_builder())
|
|
88
|
+
pipeline.connect("retriever.documents", "refine.documents")
|
|
89
|
+
pipeline.connect("refine.documents", "prompt.documents")
|
|
90
|
+
if generator is None:
|
|
91
|
+
if not api_key:
|
|
92
|
+
return pipeline
|
|
93
|
+
generator = OpenAIChatGenerator(
|
|
94
|
+
api_key=Secret.from_token(api_key),
|
|
95
|
+
model=model or os.environ.get("DEEPSEEK_MODEL", "deepseek-chat"),
|
|
96
|
+
api_base_url=DEEPSEEK_BASE_URL,
|
|
97
|
+
generation_kwargs={"temperature": 0},
|
|
98
|
+
max_retries=0,
|
|
99
|
+
)
|
|
100
|
+
pipeline.add_component("chat", ChatPromptAdapter())
|
|
101
|
+
pipeline.add_component("generator", generator)
|
|
102
|
+
pipeline.connect("prompt.prompt", "chat.prompt")
|
|
103
|
+
pipeline.connect("chat.messages", "generator.messages")
|
|
104
|
+
return pipeline
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Prompt construction helpers for the optional Haystack integration."""
|
|
2
|
+
|
|
3
|
+
from haystack.components.builders import PromptBuilder
|
|
4
|
+
|
|
5
|
+
PROMPT_TEMPLATE = """Question: {{ query }}
|
|
6
|
+
Context:
|
|
7
|
+
{% for document in documents %}
|
|
8
|
+
[{{ document.id }}] {{ document.content }} | {{ document.meta }}
|
|
9
|
+
{% endfor %}"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def prompt_builder() -> PromptBuilder:
|
|
13
|
+
"""Create the fixed prompt builder shared by bypass and refinement paths."""
|
|
14
|
+
return PromptBuilder(
|
|
15
|
+
template=PROMPT_TEMPLATE,
|
|
16
|
+
required_variables=["query", "documents"],
|
|
17
|
+
)
|
ragrefine/models.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Core retriever-agnostic candidate domain models."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class Candidate:
|
|
10
|
+
"""One immutable evidence unit supplied by an external retriever.
|
|
11
|
+
|
|
12
|
+
The model preserves the retriever's identifier, text, metadata, and optional
|
|
13
|
+
retrieval annotations without depending on any retrieval framework.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
text: str
|
|
18
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
19
|
+
retrieval_score: float | None = None
|
|
20
|
+
retrieval_rank: int | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class CandidateSet:
|
|
25
|
+
"""An immutable, ordered candidate collection from one named retriever."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
candidates: tuple[Candidate, ...]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class RankingSignal:
|
|
33
|
+
"""One optional, traceable ranking observation for a candidate."""
|
|
34
|
+
|
|
35
|
+
rank: int | None = None
|
|
36
|
+
score: float | None = None
|
|
37
|
+
details: Mapping[str, object] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class RefinedCandidate:
|
|
42
|
+
"""A candidate with its final ranking position and named evidence.
|
|
43
|
+
|
|
44
|
+
The wrapped candidate is always supplied by the caller; refinement never
|
|
45
|
+
creates or rewrites evidence. Retrieval provenance remains on ``candidate``.
|
|
46
|
+
``rank`` is the position after ranking; a later context-selection position
|
|
47
|
+
is recorded separately in :class:`ragrefine.selection.SelectionRecord`.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
candidate: Candidate
|
|
51
|
+
rank: int
|
|
52
|
+
signals: Mapping[str, RankingSignal] = field(default_factory=dict)
|
ragrefine/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Query-analysis helpers."""
|
|
2
|
+
|
|
3
|
+
from ragrefine.query.normalize import normalize_text, normalized_terms
|
|
4
|
+
from ragrefine.query.patterns import (
|
|
5
|
+
PatternMatch,
|
|
6
|
+
PatternRegistry,
|
|
7
|
+
PatternRule,
|
|
8
|
+
example_pattern_rules,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"PatternMatch",
|
|
13
|
+
"PatternRegistry",
|
|
14
|
+
"PatternRule",
|
|
15
|
+
"example_pattern_rules",
|
|
16
|
+
"normalize_text",
|
|
17
|
+
"normalized_terms",
|
|
18
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Deterministic, dependency-free text normalization for query analysis."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import unicodedata
|
|
5
|
+
|
|
6
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
7
|
+
_TOKEN = re.compile(r"[^\W_]+(?:[-_][^\W_]+)*", flags=re.UNICODE)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def normalize_text(text: str) -> str:
|
|
11
|
+
"""Return a NFKC-normalized, case-folded, whitespace-normalized string.
|
|
12
|
+
|
|
13
|
+
The returned value is suitable for deterministic comparison. It does not
|
|
14
|
+
stem, lemmatize, remove stop words, rewrite text, or mutate the caller's
|
|
15
|
+
original query/candidate text.
|
|
16
|
+
"""
|
|
17
|
+
return _WHITESPACE.sub(" ", unicodedata.normalize("NFKC", text).casefold()).strip()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def normalized_terms(text: str) -> tuple[str, ...]:
|
|
21
|
+
"""Extract unique normalized lexical terms in their first-seen order.
|
|
22
|
+
|
|
23
|
+
Hyphenated and underscored technical identifiers remain one term. The
|
|
24
|
+
uniqueness matches lexical coverage's set-based definition while ordering
|
|
25
|
+
makes evidence deterministic and human-readable.
|
|
26
|
+
"""
|
|
27
|
+
terms: list[str] = []
|
|
28
|
+
seen: set[str] = set()
|
|
29
|
+
for match in _TOKEN.finditer(normalize_text(text)):
|
|
30
|
+
term = match.group().replace("_", "-")
|
|
31
|
+
if term not in seen:
|
|
32
|
+
terms.append(term)
|
|
33
|
+
seen.add(term)
|
|
34
|
+
return tuple(terms)
|