longprobe 0.1.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.
- longprobe/__init__.py +122 -0
- longprobe/adapters/__init__.py +56 -0
- longprobe/adapters/base.py +23 -0
- longprobe/adapters/chroma.py +112 -0
- longprobe/adapters/http.py +234 -0
- longprobe/adapters/langchain.py +65 -0
- longprobe/adapters/llamaindex.py +48 -0
- longprobe/adapters/pinecone.py +102 -0
- longprobe/adapters/qdrant.py +103 -0
- longprobe/cli/__init__.py +0 -0
- longprobe/cli/main.py +1536 -0
- longprobe/config.py +483 -0
- longprobe/core/__init__.py +24 -0
- longprobe/core/baseline.py +383 -0
- longprobe/core/diff.py +367 -0
- longprobe/core/docparser.py +193 -0
- longprobe/core/embedder.py +223 -0
- longprobe/core/generator.py +295 -0
- longprobe/core/golden.py +401 -0
- longprobe/core/scorer.py +354 -0
- longprobe/pytest_plugin.py +339 -0
- longprobe-0.1.0.dist-info/METADATA +592 -0
- longprobe-0.1.0.dist-info/RECORD +26 -0
- longprobe-0.1.0.dist-info/WHEEL +4 -0
- longprobe-0.1.0.dist-info/entry_points.txt +5 -0
- longprobe-0.1.0.dist-info/licenses/LICENSE +21 -0
longprobe/__init__.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LongProbe — RAG retrieval regression testing harness.
|
|
3
|
+
|
|
4
|
+
Define Golden Questions once. Run ``longprobe check`` on every commit.
|
|
5
|
+
Get an exact diff of which document chunks were lost — before your users notice.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from longprobe.core.golden import GoldenQuestion, GoldenSet
|
|
9
|
+
from longprobe.core.scorer import RecallScorer, QuestionResult, ProbeReport
|
|
10
|
+
from longprobe.core.embedder import QueryEmbedder
|
|
11
|
+
from longprobe.core.baseline import BaselineStore
|
|
12
|
+
from longprobe.core.diff import DiffReporter, RegressionDiff
|
|
13
|
+
from longprobe.config import ProbeConfig
|
|
14
|
+
from longprobe.adapters.base import AbstractRetrieverAdapter
|
|
15
|
+
from longprobe.adapters import create_adapter
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"GoldenQuestion",
|
|
21
|
+
"GoldenSet",
|
|
22
|
+
"RecallScorer",
|
|
23
|
+
"QuestionResult",
|
|
24
|
+
"ProbeReport",
|
|
25
|
+
"QueryEmbedder",
|
|
26
|
+
"BaselineStore",
|
|
27
|
+
"DiffReporter",
|
|
28
|
+
"RegressionDiff",
|
|
29
|
+
"ProbeConfig",
|
|
30
|
+
"AbstractRetrieverAdapter",
|
|
31
|
+
"create_adapter",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LongProbe:
|
|
36
|
+
"""
|
|
37
|
+
High-level facade for running RAG regression probes.
|
|
38
|
+
|
|
39
|
+
Usage::
|
|
40
|
+
|
|
41
|
+
from longprobe import LongProbe, ChromaAdapter
|
|
42
|
+
|
|
43
|
+
adapter = ChromaAdapter(collection_name="my_docs", persist_directory="./db")
|
|
44
|
+
probe = LongProbe(adapter=adapter, goldens_path="goldens.yaml")
|
|
45
|
+
report = probe.run()
|
|
46
|
+
print(f"Overall recall: {report.overall_recall:.2f}")
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
adapter: "AbstractRetrieverAdapter",
|
|
52
|
+
goldens_path: str = "goldens.yaml",
|
|
53
|
+
config_path: str = "longprobe.yaml",
|
|
54
|
+
recall_threshold: float | None = None,
|
|
55
|
+
):
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
|
|
58
|
+
self.adapter = adapter
|
|
59
|
+
self.golden_set = GoldenSet.from_yaml(goldens_path)
|
|
60
|
+
|
|
61
|
+
config_file = Path(config_path)
|
|
62
|
+
if config_file.exists():
|
|
63
|
+
self.config = ProbeConfig.from_yaml(str(config_file))
|
|
64
|
+
else:
|
|
65
|
+
self.config = ProbeConfig.defaults()
|
|
66
|
+
|
|
67
|
+
if recall_threshold is not None:
|
|
68
|
+
self.config.scoring.recall_threshold = recall_threshold
|
|
69
|
+
|
|
70
|
+
self.scorer = RecallScorer(
|
|
71
|
+
recall_threshold=self.config.scoring.recall_threshold
|
|
72
|
+
)
|
|
73
|
+
self.baseline_store = BaselineStore(
|
|
74
|
+
db_path=self.config.baseline.db_path
|
|
75
|
+
)
|
|
76
|
+
self.diff_reporter = DiffReporter()
|
|
77
|
+
self._last_report: ProbeReport | None = None
|
|
78
|
+
|
|
79
|
+
def run(self, top_k_override: int | None = None) -> ProbeReport:
|
|
80
|
+
"""Run the probe against the golden set and return a report."""
|
|
81
|
+
report = self.scorer.score_all(
|
|
82
|
+
self.golden_set,
|
|
83
|
+
self.adapter.retrieve,
|
|
84
|
+
top_k_override=top_k_override,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Auto-compare against baseline
|
|
88
|
+
if self.config.baseline.auto_compare:
|
|
89
|
+
baseline = self.baseline_store.load("latest")
|
|
90
|
+
if baseline is not None:
|
|
91
|
+
report.baseline_recall = baseline.overall_recall
|
|
92
|
+
report.recall_delta = report.overall_recall - baseline.overall_recall
|
|
93
|
+
diff_result = self.baseline_store.diff(report, baseline)
|
|
94
|
+
report.regression_detected = len(diff_result["regressions"]) > 0
|
|
95
|
+
|
|
96
|
+
self._last_report = report
|
|
97
|
+
return report
|
|
98
|
+
|
|
99
|
+
def save_baseline(self, label: str = "latest") -> None:
|
|
100
|
+
"""Save the last run report as a baseline."""
|
|
101
|
+
if self._last_report is None:
|
|
102
|
+
raise RuntimeError("No report to save. Run probe.run() first.")
|
|
103
|
+
self.baseline_store.save(self._last_report, label=label)
|
|
104
|
+
|
|
105
|
+
def diff(self, baseline_label: str = "latest") -> dict:
|
|
106
|
+
"""Compare last run against a saved baseline."""
|
|
107
|
+
if self._last_report is None:
|
|
108
|
+
raise RuntimeError("No report to diff. Run probe.run() first.")
|
|
109
|
+
baseline = self.baseline_store.load(baseline_label)
|
|
110
|
+
if baseline is None:
|
|
111
|
+
raise ValueError(f"Baseline '{baseline_label}' not found.")
|
|
112
|
+
return self.baseline_store.diff(self._last_report, baseline)
|
|
113
|
+
|
|
114
|
+
def get_missing_chunks(self) -> dict[str, list[str]]:
|
|
115
|
+
"""Return a dict mapping question_id to its missing chunks."""
|
|
116
|
+
if self._last_report is None:
|
|
117
|
+
raise RuntimeError("No report available. Run probe.run() first.")
|
|
118
|
+
return {
|
|
119
|
+
r.question_id: r.missing_chunks
|
|
120
|
+
for r in self._last_report.results
|
|
121
|
+
if r.missing_chunks
|
|
122
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from .base import AbstractRetrieverAdapter
|
|
2
|
+
from .http import HttpAdapter
|
|
3
|
+
from .langchain import LangChainRetrieverAdapter
|
|
4
|
+
from .llamaindex import LlamaIndexRetrieverAdapter
|
|
5
|
+
from .pinecone import PineconeAdapter
|
|
6
|
+
from .qdrant import QdrantAdapter
|
|
7
|
+
from .chroma import ChromaAdapter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_adapter(adapter_type: str, **kwargs) -> AbstractRetrieverAdapter:
|
|
11
|
+
"""
|
|
12
|
+
Factory function to create a retriever adapter.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
adapter_type: One of "langchain", "llamaindex", "pinecone", "qdrant",
|
|
16
|
+
"chroma", "http"
|
|
17
|
+
**kwargs: Passed to the adapter constructor.
|
|
18
|
+
For "langchain" and "llamaindex": pass "retriever" kwarg
|
|
19
|
+
For "http": pass "config" kwarg (HttpRetrieverConfig)
|
|
20
|
+
For others: pass connection config kwargs
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
AbstractRetrieverAdapter instance
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
ValueError: If adapter_type is unknown
|
|
27
|
+
"""
|
|
28
|
+
adapters = {
|
|
29
|
+
"langchain": LangChainRetrieverAdapter,
|
|
30
|
+
"llamaindex": LlamaIndexRetrieverAdapter,
|
|
31
|
+
"pinecone": PineconeAdapter,
|
|
32
|
+
"qdrant": QdrantAdapter,
|
|
33
|
+
"chroma": ChromaAdapter,
|
|
34
|
+
"http": HttpAdapter,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
adapter_cls = adapters.get(adapter_type)
|
|
38
|
+
if adapter_cls is None:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"Unknown adapter type '{adapter_type}'. "
|
|
41
|
+
f"Must be one of: {', '.join(adapters.keys())}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return adapter_cls(**kwargs)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"AbstractRetrieverAdapter",
|
|
49
|
+
"HttpAdapter",
|
|
50
|
+
"LangChainRetrieverAdapter",
|
|
51
|
+
"LlamaIndexRetrieverAdapter",
|
|
52
|
+
"PineconeAdapter",
|
|
53
|
+
"QdrantAdapter",
|
|
54
|
+
"ChromaAdapter",
|
|
55
|
+
"create_adapter",
|
|
56
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AbstractRetrieverAdapter(ABC):
|
|
6
|
+
"""Base class for all retriever adapters."""
|
|
7
|
+
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def retrieve(self, query: str, top_k: int = 5) -> list[dict[str, Any]]:
|
|
10
|
+
"""
|
|
11
|
+
Retrieve documents for a query.
|
|
12
|
+
|
|
13
|
+
Returns list of dicts with keys:
|
|
14
|
+
- "id": str - document/chunk identifier
|
|
15
|
+
- "text": str - document text content
|
|
16
|
+
- "score": float - relevance score
|
|
17
|
+
- "metadata": dict - additional metadata
|
|
18
|
+
"""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def health_check(self) -> bool:
|
|
22
|
+
"""Check if the retriever is reachable. Override for custom health checks."""
|
|
23
|
+
return True
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .base import AbstractRetrieverAdapter
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ChromaAdapter(AbstractRetrieverAdapter):
|
|
12
|
+
"""Adapter for Chroma vector databases.
|
|
13
|
+
|
|
14
|
+
Supports both text-based and embedding-based queries. The Chroma
|
|
15
|
+
client SDK is imported lazily and cached after the first access.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
collection_name: str = "default",
|
|
21
|
+
persist_directory: str = "",
|
|
22
|
+
) -> None:
|
|
23
|
+
self.collection_name = collection_name
|
|
24
|
+
self.persist_directory = persist_directory
|
|
25
|
+
self._client: Any = None
|
|
26
|
+
|
|
27
|
+
def _get_client(self) -> Any:
|
|
28
|
+
"""Lazily create and cache a Chroma client."""
|
|
29
|
+
if self._client is not None:
|
|
30
|
+
return self._client
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import chromadb
|
|
34
|
+
except ImportError:
|
|
35
|
+
raise ImportError(
|
|
36
|
+
"chromadb is required for ChromaAdapter. "
|
|
37
|
+
"Install it with: pip install chromadb"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if self.persist_directory:
|
|
41
|
+
self._client = chromadb.PersistentClient(path=self.persist_directory)
|
|
42
|
+
else:
|
|
43
|
+
self._client = chromadb.Client()
|
|
44
|
+
|
|
45
|
+
return self._client
|
|
46
|
+
|
|
47
|
+
def retrieve(
|
|
48
|
+
self,
|
|
49
|
+
query: str | list[float],
|
|
50
|
+
top_k: int = 5,
|
|
51
|
+
) -> list[dict[str, Any]]:
|
|
52
|
+
"""Query a Chroma collection.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
query: Either a text string or a pre-computed embedding vector.
|
|
56
|
+
top_k: Number of results to return.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
List of result dicts normalised to the LongProbe format.
|
|
60
|
+
Scores are converted from Chroma distances (``1 - distance``).
|
|
61
|
+
"""
|
|
62
|
+
client = self._get_client()
|
|
63
|
+
collection = client.get_collection(self.collection_name)
|
|
64
|
+
|
|
65
|
+
if isinstance(query, str):
|
|
66
|
+
results = collection.query(
|
|
67
|
+
query_texts=[query],
|
|
68
|
+
n_results=top_k,
|
|
69
|
+
include=["documents", "metadatas", "distances"],
|
|
70
|
+
)
|
|
71
|
+
elif isinstance(query, list):
|
|
72
|
+
results = collection.query(
|
|
73
|
+
query_embeddings=[query],
|
|
74
|
+
n_results=top_k,
|
|
75
|
+
include=["documents", "metadatas", "distances"],
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
raise TypeError(
|
|
79
|
+
f"Query must be a str or list[float], got {type(query).__name__}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
ids = results.get("ids", [[]])[0]
|
|
83
|
+
documents = results.get("documents", [[]])[0]
|
|
84
|
+
distances = results.get("distances", [[]])[0]
|
|
85
|
+
metadatas = results.get("metadatas", [[]])[0]
|
|
86
|
+
|
|
87
|
+
mapped: list[dict[str, Any]] = []
|
|
88
|
+
for i in range(len(ids)):
|
|
89
|
+
mapped.append(
|
|
90
|
+
{
|
|
91
|
+
"id": ids[i],
|
|
92
|
+
"text": documents[i] if documents and i < len(documents) else "",
|
|
93
|
+
"score": 1.0 - (distances[i] if distances and i < len(distances) else 0.0),
|
|
94
|
+
"metadata": (
|
|
95
|
+
metadatas[i]
|
|
96
|
+
if metadatas and i < len(metadatas)
|
|
97
|
+
else {}
|
|
98
|
+
),
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return mapped
|
|
103
|
+
|
|
104
|
+
def health_check(self) -> bool:
|
|
105
|
+
"""Check connectivity by listing collections."""
|
|
106
|
+
try:
|
|
107
|
+
client = self._get_client()
|
|
108
|
+
client.list_collections()
|
|
109
|
+
return True
|
|
110
|
+
except Exception:
|
|
111
|
+
logger.debug("Chroma health check failed", exc_info=True)
|
|
112
|
+
return False
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Generic HTTP retriever adapter for LongProbe.
|
|
2
|
+
|
|
3
|
+
Sends queries to any HTTP-based RAG API endpoint and maps the JSON
|
|
4
|
+
response back to LongProbe's standard format. This adapter lets LongProbe
|
|
5
|
+
test the *entire* retrieval pipeline (including any reranking, prompt
|
|
6
|
+
rewriting, or filtering) rather than just the raw vector database.
|
|
7
|
+
|
|
8
|
+
Typical configuration in ``longprobe.yaml``::
|
|
9
|
+
|
|
10
|
+
retriever:
|
|
11
|
+
type: http
|
|
12
|
+
http:
|
|
13
|
+
url: "http://localhost:8000/api/retrieve"
|
|
14
|
+
method: POST
|
|
15
|
+
body_template: '{"query": "{question}", "top_k": {top_k}}'
|
|
16
|
+
headers:
|
|
17
|
+
Authorization: "Bearer ${API_KEY}"
|
|
18
|
+
response_mapping:
|
|
19
|
+
results_path: "data.chunks"
|
|
20
|
+
id_field: "chunk_id"
|
|
21
|
+
text_field: "content"
|
|
22
|
+
score_field: "similarity"
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import requests
|
|
32
|
+
|
|
33
|
+
from .base import AbstractRetrieverAdapter
|
|
34
|
+
from ..config import HttpRetrieverConfig
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HttpAdapter(AbstractRetrieverAdapter):
|
|
40
|
+
"""Adapter that queries an HTTP-based RAG API endpoint.
|
|
41
|
+
|
|
42
|
+
The request body is built from ``body_template`` by replacing
|
|
43
|
+
``{question}`` and ``{top_k}`` placeholders. The JSON response is
|
|
44
|
+
parsed using ``response_mapping`` to locate the results array and
|
|
45
|
+
extract per-result fields.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
config: HTTP-specific configuration (URL, method, headers, etc.).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, config: HttpRetrieverConfig) -> None:
|
|
52
|
+
self._config = config
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
# Public API
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def retrieve(self, query: str, top_k: int = 5) -> list[dict[str, Any]]:
|
|
59
|
+
"""Send *query* to the configured HTTP endpoint and return normalised
|
|
60
|
+
results.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
query: Natural-language question to send.
|
|
64
|
+
top_k: Number of results to request.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
List of dicts with ``id``, ``text``, ``score``, and ``metadata``
|
|
68
|
+
keys.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
RuntimeError: If the endpoint returns a non-2xx status.
|
|
72
|
+
ValueError: If the body template produces invalid JSON after
|
|
73
|
+
placeholder substitution.
|
|
74
|
+
requests.Timeout: If the request exceeds the configured timeout.
|
|
75
|
+
"""
|
|
76
|
+
cfg = self._config
|
|
77
|
+
|
|
78
|
+
# 1. Build request body from template.
|
|
79
|
+
body = self._build_body(cfg.body_template, query, top_k)
|
|
80
|
+
|
|
81
|
+
# 2. Make HTTP request.
|
|
82
|
+
try:
|
|
83
|
+
response = requests.request(
|
|
84
|
+
method=cfg.method,
|
|
85
|
+
url=cfg.url,
|
|
86
|
+
json=body,
|
|
87
|
+
headers=cfg.headers,
|
|
88
|
+
timeout=cfg.timeout,
|
|
89
|
+
)
|
|
90
|
+
except requests.Timeout as exc:
|
|
91
|
+
raise requests.Timeout(
|
|
92
|
+
f"HTTP adapter request timed out after {cfg.timeout}s "
|
|
93
|
+
f"for URL '{cfg.url}'."
|
|
94
|
+
) from exc
|
|
95
|
+
|
|
96
|
+
# 3. Check status.
|
|
97
|
+
if response.status_code >= 300:
|
|
98
|
+
snippet = response.text[:500] if response.text else "(empty body)"
|
|
99
|
+
raise RuntimeError(
|
|
100
|
+
f"HTTP adapter received status {response.status_code} from "
|
|
101
|
+
f"'{cfg.url}': {snippet}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# 4. Parse response.
|
|
105
|
+
try:
|
|
106
|
+
data = response.json()
|
|
107
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
108
|
+
raise RuntimeError(
|
|
109
|
+
f"HTTP adapter received non-JSON response from '{cfg.url}': "
|
|
110
|
+
f"{response.text[:200]}"
|
|
111
|
+
) from exc
|
|
112
|
+
|
|
113
|
+
# 5. Resolve results path.
|
|
114
|
+
mapping = cfg.response_mapping
|
|
115
|
+
items = self._resolve_path(data, mapping.results_path)
|
|
116
|
+
|
|
117
|
+
if not isinstance(items, list):
|
|
118
|
+
logger.warning(
|
|
119
|
+
"HTTP adapter: path '%s' did not resolve to a list "
|
|
120
|
+
"(got %s). Returning empty results.",
|
|
121
|
+
mapping.results_path,
|
|
122
|
+
type(items).__name__,
|
|
123
|
+
)
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
# 6. Map each item to LongProbe format.
|
|
127
|
+
results: list[dict[str, Any]] = []
|
|
128
|
+
for item in items:
|
|
129
|
+
if not isinstance(item, dict):
|
|
130
|
+
logger.warning(
|
|
131
|
+
"HTTP adapter: skipping non-dict item in results array."
|
|
132
|
+
)
|
|
133
|
+
continue
|
|
134
|
+
results.append(
|
|
135
|
+
{
|
|
136
|
+
"id": str(item.get(mapping.id_field, "")),
|
|
137
|
+
"text": str(item.get(mapping.text_field, "")),
|
|
138
|
+
"score": float(item.get(mapping.score_field, 0.0)),
|
|
139
|
+
"metadata": {
|
|
140
|
+
k: v
|
|
141
|
+
for k, v in item.items()
|
|
142
|
+
if k not in (mapping.id_field, mapping.text_field, mapping.score_field)
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return results
|
|
148
|
+
|
|
149
|
+
def health_check(self) -> bool:
|
|
150
|
+
"""Check if the configured endpoint is reachable.
|
|
151
|
+
|
|
152
|
+
Sends a HEAD (or GET for endpoints that don't support HEAD) to the
|
|
153
|
+
URL and returns ``True`` for any response with status < 400.
|
|
154
|
+
Timeouts and connection errors return ``False``.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
# Try HEAD first (lighter).
|
|
158
|
+
resp = requests.head(
|
|
159
|
+
self._config.url,
|
|
160
|
+
headers=self._config.headers,
|
|
161
|
+
timeout=min(self._config.timeout, 10),
|
|
162
|
+
)
|
|
163
|
+
if resp.status_code == 405:
|
|
164
|
+
# Fallback to GET if HEAD is not allowed.
|
|
165
|
+
resp = requests.get(
|
|
166
|
+
self._config.url,
|
|
167
|
+
headers=self._config.headers,
|
|
168
|
+
timeout=min(self._config.timeout, 10),
|
|
169
|
+
)
|
|
170
|
+
return resp.status_code < 400
|
|
171
|
+
except (requests.RequestException, OSError):
|
|
172
|
+
logger.debug("HTTP adapter health check failed", exc_info=True)
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# Internal helpers
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _build_body(
|
|
181
|
+
template: str,
|
|
182
|
+
query: str,
|
|
183
|
+
top_k: int,
|
|
184
|
+
) -> Any:
|
|
185
|
+
"""Substitute ``{question}`` and ``{top_k}`` in *template* and
|
|
186
|
+
parse the result as JSON.
|
|
187
|
+
|
|
188
|
+
``{question}`` is replaced with a JSON-safe escaped string.
|
|
189
|
+
``{top_k}`` is replaced with the integer value.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
The parsed JSON object (usually a dict).
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
ValueError: If the substituted template is not valid JSON.
|
|
196
|
+
"""
|
|
197
|
+
# JSON-escape the question string, then strip surrounding quotes
|
|
198
|
+
# so it fits naturally inside the template's own quoting.
|
|
199
|
+
escaped_question = json.dumps(query)[1:-1]
|
|
200
|
+
body_str = template.replace("{question}", escaped_question)
|
|
201
|
+
body_str = body_str.replace("{top_k}", str(top_k))
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
return json.loads(body_str)
|
|
205
|
+
except json.JSONDecodeError as exc:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"HTTP adapter body template produced invalid JSON after "
|
|
208
|
+
f"substitution: {exc}\n"
|
|
209
|
+
f"Template: {template}\n"
|
|
210
|
+
f"Result: {body_str[:200]}"
|
|
211
|
+
) from exc
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _resolve_path(data: Any, path: str) -> Any:
|
|
215
|
+
"""Traverse *data* using a dot-notation *path*.
|
|
216
|
+
|
|
217
|
+
``\"data.chunks\"`` resolves to ``data[\"data\"][\"chunks\"]``.
|
|
218
|
+
If any segment is missing, an empty list is returned.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
data: The parsed JSON response.
|
|
222
|
+
path: Dot-separated key path.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
The value at the path, or an empty list if not found.
|
|
226
|
+
"""
|
|
227
|
+
current = data
|
|
228
|
+
parts = path.split(".") if path else []
|
|
229
|
+
for part in parts:
|
|
230
|
+
if isinstance(current, dict) and part in current:
|
|
231
|
+
current = current[part]
|
|
232
|
+
else:
|
|
233
|
+
return []
|
|
234
|
+
return current
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .base import AbstractRetrieverAdapter
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LangChainRetrieverAdapter(AbstractRetrieverAdapter):
|
|
12
|
+
"""Adapter for LangChain BaseRetriever objects.
|
|
13
|
+
|
|
14
|
+
Accepts any LangChain retriever (duck-typed) and normalises its
|
|
15
|
+
output into the LongProbe result format.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, retriever: Any) -> None:
|
|
19
|
+
self.retriever = retriever
|
|
20
|
+
|
|
21
|
+
def retrieve(self, query: str, top_k: int = 5) -> list[dict[str, Any]]:
|
|
22
|
+
"""Retrieve documents using a LangChain retriever.
|
|
23
|
+
|
|
24
|
+
Tries the modern ``invoke`` API first, then falls back to the
|
|
25
|
+
legacy ``get_relevant_documents`` method.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
from langchain_core.documents import Document # noqa: F401 – ensure importable
|
|
29
|
+
except ImportError:
|
|
30
|
+
raise ImportError(
|
|
31
|
+
"langchain-core is required for LangChainRetrieverAdapter. "
|
|
32
|
+
"Install it with: pip install langchain-core"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Invoke the retriever
|
|
36
|
+
documents = []
|
|
37
|
+
try:
|
|
38
|
+
documents = self.retriever.invoke(query)
|
|
39
|
+
except AttributeError:
|
|
40
|
+
# Legacy LangChain API
|
|
41
|
+
documents = self.retriever.get_relevant_documents(query)
|
|
42
|
+
|
|
43
|
+
results: list[dict[str, Any]] = []
|
|
44
|
+
for i, d in enumerate(documents):
|
|
45
|
+
doc_id = d.metadata.get("chunk_id", d.metadata.get("source", f"doc_{i}"))
|
|
46
|
+
results.append(
|
|
47
|
+
{
|
|
48
|
+
"id": doc_id,
|
|
49
|
+
"text": d.page_content,
|
|
50
|
+
"score": float(d.metadata.get("score", 0.0)),
|
|
51
|
+
"metadata": dict(d.metadata),
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return results[:top_k]
|
|
56
|
+
|
|
57
|
+
def health_check(self) -> bool:
|
|
58
|
+
"""Check that the underlying retriever is callable."""
|
|
59
|
+
try:
|
|
60
|
+
return (
|
|
61
|
+
hasattr(self.retriever, "invoke")
|
|
62
|
+
or hasattr(self.retriever, "get_relevant_documents")
|
|
63
|
+
)
|
|
64
|
+
except Exception:
|
|
65
|
+
return False
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .base import AbstractRetrieverAdapter
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LlamaIndexRetrieverAdapter(AbstractRetrieverAdapter):
|
|
12
|
+
"""Adapter for LlamaIndex retriever objects.
|
|
13
|
+
|
|
14
|
+
Accepts any LlamaIndex retriever and normalises its
|
|
15
|
+
``NodeWithScore`` results into the LongProbe format.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, retriever: Any) -> None:
|
|
19
|
+
self.retriever = retriever
|
|
20
|
+
|
|
21
|
+
def retrieve(self, query: str, top_k: int = 5) -> list[dict[str, Any]]:
|
|
22
|
+
"""Retrieve documents using a LlamaIndex retriever."""
|
|
23
|
+
try:
|
|
24
|
+
from llama_index.core import QueryBundle # noqa: F401
|
|
25
|
+
except ImportError:
|
|
26
|
+
raise ImportError(
|
|
27
|
+
"llama-index-core is required for LlamaIndexRetrieverAdapter. "
|
|
28
|
+
"Install it with: pip install llama-index-core"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
nodes_with_scores = self.retriever.retrieve(query)
|
|
32
|
+
|
|
33
|
+
results: list[dict[str, Any]] = []
|
|
34
|
+
for node in nodes_with_scores:
|
|
35
|
+
results.append(
|
|
36
|
+
{
|
|
37
|
+
"id": node.node.node_id,
|
|
38
|
+
"text": node.node.get_content(),
|
|
39
|
+
"score": float(node.score or 0.0),
|
|
40
|
+
"metadata": dict(node.node.metadata),
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return results[:top_k]
|
|
45
|
+
|
|
46
|
+
def health_check(self) -> bool:
|
|
47
|
+
"""Check that the underlying retriever has a ``retrieve`` method."""
|
|
48
|
+
return hasattr(self.retriever, "retrieve")
|