rag-python 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.
- rag_python/__init__.py +39 -0
- rag_python/chunking.py +181 -0
- rag_python/cleaning.py +102 -0
- rag_python/cli.py +77 -0
- rag_python/client.py +190 -0
- rag_python/config.py +37 -0
- rag_python/document_loaders.py +74 -0
- rag_python/evaluation.py +105 -0
- rag_python/generation.py +35 -0
- rag_python/guardrails.py +66 -0
- rag_python/options.py +68 -0
- rag_python/providers/__init__.py +5 -0
- rag_python/providers/anthropic_provider.py +41 -0
- rag_python/providers/azure_openai_provider.py +62 -0
- rag_python/providers/base.py +24 -0
- rag_python/providers/factory.py +53 -0
- rag_python/providers/gemini_provider.py +45 -0
- rag_python/providers/ollama_provider.py +56 -0
- rag_python/providers/openai_provider.py +46 -0
- rag_python/py.typed +0 -0
- rag_python/query_rewriting.py +65 -0
- rag_python/rag_pipeline.py +241 -0
- rag_python/reranker.py +64 -0
- rag_python/retrieval.py +61 -0
- rag_python/vector_store.py +91 -0
- rag_python-0.1.0.dist-info/LICENSE +22 -0
- rag_python-0.1.0.dist-info/METADATA +158 -0
- rag_python-0.1.0.dist-info/RECORD +31 -0
- rag_python-0.1.0.dist-info/WHEEL +5 -0
- rag_python-0.1.0.dist-info/entry_points.txt +2 -0
- rag_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from openai import OpenAI
|
|
4
|
+
|
|
5
|
+
from ..config import OPENAI_API_KEY, LLM_MODEL, EMBEDDING_MODEL
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpenAIProvider:
|
|
9
|
+
def __init__(self, *, api_key: str | None = None) -> None:
|
|
10
|
+
api_key = api_key or OPENAI_API_KEY
|
|
11
|
+
if not api_key:
|
|
12
|
+
raise RuntimeError("OpenAI API key missing. Set OPENAI_API_KEY or pass api_key=...")
|
|
13
|
+
self._client = OpenAI(api_key=api_key)
|
|
14
|
+
|
|
15
|
+
def generate(
|
|
16
|
+
self,
|
|
17
|
+
*,
|
|
18
|
+
user: str,
|
|
19
|
+
system: str | None = None,
|
|
20
|
+
model: str | None = None,
|
|
21
|
+
temperature: float = 0.2,
|
|
22
|
+
max_tokens: int = 1024,
|
|
23
|
+
) -> str:
|
|
24
|
+
r = self._client.chat.completions.create(
|
|
25
|
+
model=model or LLM_MODEL,
|
|
26
|
+
messages=[
|
|
27
|
+
{"role": "system", "content": system or "You are a helpful assistant."},
|
|
28
|
+
{"role": "user", "content": user},
|
|
29
|
+
],
|
|
30
|
+
temperature=temperature,
|
|
31
|
+
max_tokens=max_tokens,
|
|
32
|
+
)
|
|
33
|
+
return (r.choices[0].message.content or "").strip()
|
|
34
|
+
|
|
35
|
+
def embed(self, texts: list[str], *, model: str | None = None) -> list[list[float]]:
|
|
36
|
+
if not texts:
|
|
37
|
+
return []
|
|
38
|
+
out: list[list[float]] = []
|
|
39
|
+
batch_size = 100
|
|
40
|
+
for i in range(0, len(texts), batch_size):
|
|
41
|
+
batch = [t[:8000] for t in texts[i : i + batch_size]]
|
|
42
|
+
r = self._client.embeddings.create(input=batch, model=model or EMBEDDING_MODEL)
|
|
43
|
+
for e in r.data:
|
|
44
|
+
out.append(e.embedding)
|
|
45
|
+
return out
|
|
46
|
+
|
rag_python/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Query understanding & rewriting: expand/rewrite user query for better retrieval."""
|
|
2
|
+
from .config import LLM_MODEL
|
|
3
|
+
from .providers import LLMProvider, make_llm_provider
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def rewrite_for_retrieval(
|
|
7
|
+
query: str,
|
|
8
|
+
*,
|
|
9
|
+
n_queries: int = 3,
|
|
10
|
+
llm: LLMProvider | None = None,
|
|
11
|
+
llm_model: str | None = None,
|
|
12
|
+
) -> list[str]:
|
|
13
|
+
"""Generate n alternative queries (multi-query) for better recall."""
|
|
14
|
+
if n_queries <= 1:
|
|
15
|
+
return [query]
|
|
16
|
+
llm = llm or make_llm_provider("openai")
|
|
17
|
+
prompt = (
|
|
18
|
+
"You are a search expert. Given the user question below, generate "
|
|
19
|
+
f"{n_queries} different search queries that would help find relevant documents. "
|
|
20
|
+
"Output ONLY the queries, one per line, no numbering or bullets."
|
|
21
|
+
)
|
|
22
|
+
try:
|
|
23
|
+
text = llm.generate(
|
|
24
|
+
system=prompt,
|
|
25
|
+
user=query,
|
|
26
|
+
model=llm_model or LLM_MODEL,
|
|
27
|
+
temperature=0.3,
|
|
28
|
+
max_tokens=200,
|
|
29
|
+
)
|
|
30
|
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
31
|
+
cleaned = []
|
|
32
|
+
for line in lines[:n_queries]:
|
|
33
|
+
for sep in (". ", ") ", "- ", " ", ""):
|
|
34
|
+
if line.startswith(sep):
|
|
35
|
+
line = line[len(sep):].strip()
|
|
36
|
+
if line and not line[0].isdigit():
|
|
37
|
+
break
|
|
38
|
+
if line:
|
|
39
|
+
cleaned.append(line)
|
|
40
|
+
if not cleaned:
|
|
41
|
+
return [query]
|
|
42
|
+
return cleaned[:n_queries]
|
|
43
|
+
except Exception:
|
|
44
|
+
return [query]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def understand_query(query: str, *, llm: LLMProvider | None = None, llm_model: str | None = None) -> str:
|
|
48
|
+
"""Optional: rewrite single query for clarity (e.g. spell correction, intent)."""
|
|
49
|
+
llm = llm or make_llm_provider("openai")
|
|
50
|
+
try:
|
|
51
|
+
out = llm.generate(
|
|
52
|
+
system=(
|
|
53
|
+
"Rewrite the user's question to be a clear, standalone search query. "
|
|
54
|
+
"Preserve intent. Output only the rewritten query."
|
|
55
|
+
),
|
|
56
|
+
user=query,
|
|
57
|
+
model=llm_model or LLM_MODEL,
|
|
58
|
+
temperature=0,
|
|
59
|
+
max_tokens=150,
|
|
60
|
+
)
|
|
61
|
+
out = (out or "").strip().strip('"')
|
|
62
|
+
return out if out else query
|
|
63
|
+
except Exception:
|
|
64
|
+
return query
|
|
65
|
+
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Full RAG pipeline: Query → Understanding/Rewrite → Retrieval (multi-query) → Rerank → LLM → Guardrails → Eval/Retry."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .cleaning import clean_document
|
|
6
|
+
from .chunking import chunk_text, Chunk
|
|
7
|
+
from .document_loaders import load_file, load_directory, LoadedDocument
|
|
8
|
+
from .vector_store import ingest_chunks, delete_all
|
|
9
|
+
from .retrieval import retrieve as rag_retrieve
|
|
10
|
+
from .generation import generate
|
|
11
|
+
from .guardrails import check_prompt_injection, check_hallucination
|
|
12
|
+
from .evaluation import evaluate_rag, should_retry, self_correct
|
|
13
|
+
from .providers import LLMProvider, EmbeddingProvider, make_llm_provider, make_embedding_provider
|
|
14
|
+
from .config import DATA_DIR, CHUNK_SIZE, CHUNK_OVERLAP, CHUNK_STRATEGY
|
|
15
|
+
from .options import QueryConfig, SearchConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class RAGResponse:
|
|
20
|
+
answer: str
|
|
21
|
+
sources: list[dict]
|
|
22
|
+
evaluation: dict
|
|
23
|
+
retried: bool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _embed_fn_for_semantic(embedder: EmbeddingProvider, embedding_model: str | None):
|
|
27
|
+
def _fn(texts: list[str]):
|
|
28
|
+
return embedder.embed(texts, model=embedding_model)
|
|
29
|
+
|
|
30
|
+
return _fn
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_documents(
|
|
34
|
+
paths: list[Path] | None = None,
|
|
35
|
+
data_path: Path | None = None,
|
|
36
|
+
*,
|
|
37
|
+
extensions: tuple[str, ...] = (".txt", ".md", ".pdf", ".docx"),
|
|
38
|
+
) -> list[LoadedDocument]:
|
|
39
|
+
"""Load documents from explicit paths and/or a data directory."""
|
|
40
|
+
docs: list[LoadedDocument] = []
|
|
41
|
+
seen_sources: set[str] = set()
|
|
42
|
+
|
|
43
|
+
if paths:
|
|
44
|
+
for raw in paths:
|
|
45
|
+
path = Path(raw)
|
|
46
|
+
if path.is_file():
|
|
47
|
+
doc = load_file(path)
|
|
48
|
+
if doc and doc.content.strip() and doc.source not in seen_sources:
|
|
49
|
+
seen_sources.add(doc.source)
|
|
50
|
+
docs.append(doc)
|
|
51
|
+
elif path.is_dir():
|
|
52
|
+
for doc in load_directory(path, extensions=extensions):
|
|
53
|
+
if doc.source not in seen_sources:
|
|
54
|
+
seen_sources.add(doc.source)
|
|
55
|
+
docs.append(doc)
|
|
56
|
+
|
|
57
|
+
if data_path is not None:
|
|
58
|
+
root = Path(data_path)
|
|
59
|
+
if root.is_file():
|
|
60
|
+
doc = load_file(root)
|
|
61
|
+
if doc and doc.content.strip() and doc.source not in seen_sources:
|
|
62
|
+
docs.append(doc)
|
|
63
|
+
elif root.is_dir():
|
|
64
|
+
for doc in load_directory(root, extensions=extensions):
|
|
65
|
+
if doc.source not in seen_sources:
|
|
66
|
+
seen_sources.add(doc.source)
|
|
67
|
+
docs.append(doc)
|
|
68
|
+
|
|
69
|
+
return docs
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _ingest_documents(
|
|
73
|
+
docs: list[LoadedDocument],
|
|
74
|
+
*,
|
|
75
|
+
clean: bool,
|
|
76
|
+
chunk_strategy: str,
|
|
77
|
+
chunk_size: int,
|
|
78
|
+
chunk_overlap: int,
|
|
79
|
+
embedding_model: str | None,
|
|
80
|
+
embedder: EmbeddingProvider,
|
|
81
|
+
) -> int:
|
|
82
|
+
all_chunks: list[Chunk] = []
|
|
83
|
+
for doc in docs:
|
|
84
|
+
text = doc.content
|
|
85
|
+
if clean:
|
|
86
|
+
text = clean_document(text)
|
|
87
|
+
if chunk_strategy == "semantic":
|
|
88
|
+
chunks = chunk_text(
|
|
89
|
+
text,
|
|
90
|
+
strategy="semantic",
|
|
91
|
+
chunk_size=chunk_size,
|
|
92
|
+
overlap=chunk_overlap,
|
|
93
|
+
metadata=doc.metadata,
|
|
94
|
+
embed_fn=_embed_fn_for_semantic(embedder, embedding_model),
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
chunks = chunk_text(
|
|
98
|
+
text,
|
|
99
|
+
strategy=chunk_strategy,
|
|
100
|
+
chunk_size=chunk_size,
|
|
101
|
+
overlap=chunk_overlap,
|
|
102
|
+
metadata=doc.metadata,
|
|
103
|
+
)
|
|
104
|
+
all_chunks.extend(chunks)
|
|
105
|
+
|
|
106
|
+
if not all_chunks:
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
ids = [f"chunk_{i}" for i in range(len(all_chunks))]
|
|
110
|
+
texts = [c.text for c in all_chunks]
|
|
111
|
+
metadatas = [c.metadata for c in all_chunks]
|
|
112
|
+
ingest_chunks(ids, texts, metadatas, embedder=embedder, embed_model=embedding_model)
|
|
113
|
+
return len(all_chunks)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def ingest(
|
|
117
|
+
data_path: Path | str | None = None,
|
|
118
|
+
*,
|
|
119
|
+
paths: list[Path | str] | None = None,
|
|
120
|
+
clean: bool = True,
|
|
121
|
+
chunk_strategy: str | None = None,
|
|
122
|
+
chunk_size: int | None = None,
|
|
123
|
+
chunk_overlap: int | None = None,
|
|
124
|
+
extensions: tuple[str, ...] | None = None,
|
|
125
|
+
reindex: bool = False,
|
|
126
|
+
embedding_model: str | None = None,
|
|
127
|
+
embedder: EmbeddingProvider | None = None,
|
|
128
|
+
) -> int:
|
|
129
|
+
"""
|
|
130
|
+
Load documents → clean → chunk → embed → vector store.
|
|
131
|
+
Pass ``paths`` for multiple files/folders, or ``data_path`` for a single root.
|
|
132
|
+
Returns number of chunks ingested.
|
|
133
|
+
"""
|
|
134
|
+
if reindex:
|
|
135
|
+
delete_all()
|
|
136
|
+
strategy = chunk_strategy or CHUNK_STRATEGY
|
|
137
|
+
size = chunk_size or CHUNK_SIZE
|
|
138
|
+
overlap = chunk_overlap or CHUNK_OVERLAP
|
|
139
|
+
ext = extensions or (".txt", ".md", ".pdf", ".docx")
|
|
140
|
+
embedder = embedder or make_embedding_provider("openai")
|
|
141
|
+
|
|
142
|
+
path_list = [Path(p) for p in paths] if paths else None
|
|
143
|
+
root = Path(data_path) if data_path else (None if path_list else Path(DATA_DIR))
|
|
144
|
+
docs = _load_documents(path_list, root, extensions=ext)
|
|
145
|
+
return _ingest_documents(
|
|
146
|
+
docs,
|
|
147
|
+
clean=clean,
|
|
148
|
+
chunk_strategy=strategy,
|
|
149
|
+
chunk_size=size,
|
|
150
|
+
chunk_overlap=overlap,
|
|
151
|
+
embedding_model=embedding_model,
|
|
152
|
+
embedder=embedder,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def query(
|
|
157
|
+
question: str,
|
|
158
|
+
*,
|
|
159
|
+
search: SearchConfig | None = None,
|
|
160
|
+
query_config: QueryConfig | None = None,
|
|
161
|
+
multi_query: bool | None = None,
|
|
162
|
+
use_guardrails: bool | None = None,
|
|
163
|
+
use_retry: bool | None = None,
|
|
164
|
+
eval_threshold: float | None = None,
|
|
165
|
+
max_retries: int | None = None,
|
|
166
|
+
llm_model: str | None = None,
|
|
167
|
+
embedding_model: str | None = None,
|
|
168
|
+
llm: LLMProvider | None = None,
|
|
169
|
+
embedder: EmbeddingProvider | None = None,
|
|
170
|
+
) -> RAGResponse:
|
|
171
|
+
"""
|
|
172
|
+
Run full pipeline: input guardrails → retrieval → rerank → generate →
|
|
173
|
+
output guardrails → evaluation → retry/self-correction if needed.
|
|
174
|
+
"""
|
|
175
|
+
search_cfg = search or SearchConfig()
|
|
176
|
+
q_cfg = query_config or QueryConfig()
|
|
177
|
+
use_guardrails = q_cfg.use_guardrails if use_guardrails is None else use_guardrails
|
|
178
|
+
use_retry = q_cfg.use_retry if use_retry is None else use_retry
|
|
179
|
+
eval_threshold = q_cfg.eval_threshold if eval_threshold is None else eval_threshold
|
|
180
|
+
max_retries = q_cfg.max_retries if max_retries is None else max_retries
|
|
181
|
+
|
|
182
|
+
llm = llm or make_llm_provider("openai")
|
|
183
|
+
embedder = embedder or make_embedding_provider("openai")
|
|
184
|
+
|
|
185
|
+
if use_guardrails:
|
|
186
|
+
safe, msg = check_prompt_injection(question, llm=llm, llm_model=llm_model)
|
|
187
|
+
if not safe:
|
|
188
|
+
return RAGResponse(
|
|
189
|
+
answer=f"Request blocked: {msg}",
|
|
190
|
+
sources=[],
|
|
191
|
+
evaluation={},
|
|
192
|
+
retried=False,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
q_understood = question
|
|
196
|
+
|
|
197
|
+
hits = rag_retrieve(
|
|
198
|
+
q_understood,
|
|
199
|
+
retriever=search_cfg.retriever,
|
|
200
|
+
multi_query=multi_query,
|
|
201
|
+
n_queries=search_cfg.multi_query_n,
|
|
202
|
+
top_k_retrieve=search_cfg.top_k_retrieve,
|
|
203
|
+
top_k_rerank=search_cfg.top_k_rerank,
|
|
204
|
+
rerank_enabled=search_cfg.rerank_enabled,
|
|
205
|
+
embedder=embedder,
|
|
206
|
+
embedding_model=embedding_model,
|
|
207
|
+
llm=llm,
|
|
208
|
+
llm_model=llm_model,
|
|
209
|
+
)
|
|
210
|
+
context_chunks = [h[0] for h in hits]
|
|
211
|
+
sources = [{"text": h[0][:200], "metadata": h[1], "score": h[2]} for h in hits]
|
|
212
|
+
context_str = "\n\n".join(context_chunks)
|
|
213
|
+
|
|
214
|
+
if not context_chunks:
|
|
215
|
+
return RAGResponse(
|
|
216
|
+
answer="No relevant documents found for your question.",
|
|
217
|
+
sources=[],
|
|
218
|
+
evaluation={"faithfulness": 1.0, "relevance": 0.0},
|
|
219
|
+
retried=False,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
answer = generate(q_understood, context_chunks, model=llm_model, llm=llm)
|
|
223
|
+
retried = False
|
|
224
|
+
|
|
225
|
+
if use_guardrails:
|
|
226
|
+
grounded, msg = check_hallucination(answer, context_str, llm=llm, llm_model=llm_model)
|
|
227
|
+
if not grounded:
|
|
228
|
+
answer = answer + f"\n\n[Note: {msg}]"
|
|
229
|
+
|
|
230
|
+
eval_scores = evaluate_rag(q_understood, answer, context_str, llm=llm, llm_model=llm_model)
|
|
231
|
+
|
|
232
|
+
if use_retry and should_retry(eval_scores["faithfulness"], eval_scores["relevance"], eval_threshold):
|
|
233
|
+
for _ in range(max(0, max_retries - 1)):
|
|
234
|
+
answer = self_correct(q_understood, answer, context_str, llm=llm, llm_model=llm_model)
|
|
235
|
+
eval_scores = evaluate_rag(q_understood, answer, context_str, llm=llm, llm_model=llm_model)
|
|
236
|
+
retried = True
|
|
237
|
+
if not should_retry(eval_scores["faithfulness"], eval_scores["relevance"], eval_threshold):
|
|
238
|
+
break
|
|
239
|
+
|
|
240
|
+
return RAGResponse(answer=answer, sources=sources, evaluation=eval_scores, retried=retried)
|
|
241
|
+
|
rag_python/reranker.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Reranking: cross-encoder to score query–document relevance."""
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from .config import RERANKER_MODEL, RERANK_ENABLED
|
|
5
|
+
|
|
6
|
+
_reranker = None
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _get_reranker():
|
|
10
|
+
global _reranker
|
|
11
|
+
if _reranker is None:
|
|
12
|
+
try:
|
|
13
|
+
from sentence_transformers import CrossEncoder
|
|
14
|
+
except ImportError as e:
|
|
15
|
+
raise ImportError(
|
|
16
|
+
"Reranking requires optional dependencies. Install with: pip install rag-python[rerank]"
|
|
17
|
+
) from e
|
|
18
|
+
_reranker = CrossEncoder(RERANKER_MODEL)
|
|
19
|
+
return _reranker
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def rerank(
|
|
23
|
+
query: str,
|
|
24
|
+
documents: list[str],
|
|
25
|
+
top_k: int = 5,
|
|
26
|
+
*,
|
|
27
|
+
rerank_enabled: bool | None = None,
|
|
28
|
+
) -> list[tuple[str, float, int]]:
|
|
29
|
+
"""Return (document, score, original_index) sorted by relevance."""
|
|
30
|
+
if not documents:
|
|
31
|
+
return []
|
|
32
|
+
use_rerank = RERANK_ENABLED if rerank_enabled is None else rerank_enabled
|
|
33
|
+
if not use_rerank:
|
|
34
|
+
return [(documents[i], 0.0, i) for i in range(min(top_k, len(documents)))]
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
pairs = [(query, d) for d in documents]
|
|
38
|
+
model = _get_reranker()
|
|
39
|
+
scores = model.predict(pairs)
|
|
40
|
+
indexed = [(documents[i], float(scores[i]), i) for i in range(len(documents))]
|
|
41
|
+
indexed.sort(key=lambda x: -x[1])
|
|
42
|
+
return indexed[: top_k if top_k < len(indexed) else len(indexed)]
|
|
43
|
+
except ImportError:
|
|
44
|
+
# Graceful fallback: keep retrieval order
|
|
45
|
+
return [(documents[i], 0.0, i) for i in range(min(top_k, len(documents)))]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def rerank_with_metadata(
|
|
49
|
+
query: str,
|
|
50
|
+
doc_meta_list: list[tuple[str, dict[str, Any]]],
|
|
51
|
+
top_k: int = 5,
|
|
52
|
+
*,
|
|
53
|
+
rerank_enabled: bool | None = None,
|
|
54
|
+
) -> list[tuple[str, dict[str, Any], float]]:
|
|
55
|
+
"""Rerank (document, metadata) pairs; return (doc, meta, score)."""
|
|
56
|
+
if not doc_meta_list:
|
|
57
|
+
return []
|
|
58
|
+
docs = [d for d, _ in doc_meta_list]
|
|
59
|
+
metas = [m for _, m in doc_meta_list]
|
|
60
|
+
results = rerank(query, docs, top_k=min(top_k, len(docs)), rerank_enabled=rerank_enabled)
|
|
61
|
+
out = []
|
|
62
|
+
for doc, score, idx in results:
|
|
63
|
+
out.append((doc, metas[idx], score))
|
|
64
|
+
return out
|
rag_python/retrieval.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Retrieval: multi-query retrieval + reranking."""
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from .vector_store import retrieve as chroma_retrieve
|
|
5
|
+
from .query_rewriting import rewrite_for_retrieval
|
|
6
|
+
from .reranker import rerank_with_metadata
|
|
7
|
+
from .providers import EmbeddingProvider, LLMProvider
|
|
8
|
+
from .options import RetrieverStrategy
|
|
9
|
+
from .config import TOP_K_RETRIEVE, TOP_K_RERANK, MULTI_QUERY_N
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def retrieve(
|
|
13
|
+
query: str,
|
|
14
|
+
*,
|
|
15
|
+
embedder: EmbeddingProvider,
|
|
16
|
+
embedding_model: str | None = None,
|
|
17
|
+
retriever: RetrieverStrategy = "multi_query",
|
|
18
|
+
multi_query: bool | None = None,
|
|
19
|
+
n_queries: int | None = None,
|
|
20
|
+
top_k_retrieve: int | None = None,
|
|
21
|
+
top_k_rerank: int | None = None,
|
|
22
|
+
rerank_enabled: bool | None = None,
|
|
23
|
+
llm: LLMProvider | None = None,
|
|
24
|
+
llm_model: str | None = None,
|
|
25
|
+
) -> list[tuple[str, dict[str, Any], float]]:
|
|
26
|
+
"""
|
|
27
|
+
Retrieve relevant chunks using vector or multi-query search, then rerank.
|
|
28
|
+
Returns list of (document_text, metadata, rerank_score).
|
|
29
|
+
"""
|
|
30
|
+
top_k_retrieve = top_k_retrieve or TOP_K_RETRIEVE
|
|
31
|
+
top_k_rerank = top_k_rerank or TOP_K_RERANK
|
|
32
|
+
n_queries = n_queries or MULTI_QUERY_N
|
|
33
|
+
use_multi_query = retriever == "multi_query" if multi_query is None else multi_query
|
|
34
|
+
|
|
35
|
+
queries = [query]
|
|
36
|
+
if use_multi_query and n_queries > 1:
|
|
37
|
+
rewritten = rewrite_for_retrieval(query, n_queries=n_queries, llm=llm, llm_model=llm_model)
|
|
38
|
+
if rewritten:
|
|
39
|
+
queries = rewritten
|
|
40
|
+
|
|
41
|
+
seen_docs: set[str] = set()
|
|
42
|
+
all_candidates: list[tuple[str, dict, float]] = []
|
|
43
|
+
for q in queries:
|
|
44
|
+
emb = embedder.embed([q], model=embedding_model)[0]
|
|
45
|
+
hits = chroma_retrieve(emb, top_k=top_k_retrieve)
|
|
46
|
+
for doc, meta, dist in hits:
|
|
47
|
+
key = (doc[:200], meta.get("source", ""))
|
|
48
|
+
if key in seen_docs:
|
|
49
|
+
continue
|
|
50
|
+
seen_docs.add(key)
|
|
51
|
+
all_candidates.append((doc, meta, -dist))
|
|
52
|
+
|
|
53
|
+
if not all_candidates:
|
|
54
|
+
return []
|
|
55
|
+
docs = [c[0] for c in all_candidates]
|
|
56
|
+
metas = [c[1] for c in all_candidates]
|
|
57
|
+
reranked = rerank_with_metadata(
|
|
58
|
+
query, list(zip(docs, metas)), top_k=top_k_rerank, rerank_enabled=rerank_enabled
|
|
59
|
+
)
|
|
60
|
+
return reranked
|
|
61
|
+
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Vector store (Chroma) for ingestion and retrieval."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import chromadb
|
|
6
|
+
from chromadb.config import Settings
|
|
7
|
+
|
|
8
|
+
from .config import CHROMA_PERSIST_DIR
|
|
9
|
+
from .providers import EmbeddingProvider
|
|
10
|
+
|
|
11
|
+
COLLECTION_NAME = "rag_docs"
|
|
12
|
+
# chromadb.PersistentClient is a factory function (not a type), so avoid using it in annotations.
|
|
13
|
+
_chroma_client: Any | None = None
|
|
14
|
+
_persist_dir: Path | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def set_persist_dir(path: Path | str) -> None:
|
|
18
|
+
"""Override Chroma persistence directory (resets the client)."""
|
|
19
|
+
global _chroma_client, _persist_dir
|
|
20
|
+
_chroma_client = None
|
|
21
|
+
_persist_dir = Path(path)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_client() -> Any:
|
|
25
|
+
global _chroma_client
|
|
26
|
+
if _chroma_client is None:
|
|
27
|
+
p = Path(_persist_dir or CHROMA_PERSIST_DIR)
|
|
28
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
_chroma_client = chromadb.PersistentClient(path=str(p), settings=Settings(anonymized_telemetry=False))
|
|
30
|
+
return _chroma_client
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_collection():
|
|
34
|
+
return _get_client().get_or_create_collection(
|
|
35
|
+
name=COLLECTION_NAME,
|
|
36
|
+
metadata={"hnsw:space": "cosine"},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ingest_chunks(
|
|
41
|
+
ids: list[str],
|
|
42
|
+
texts: list[str],
|
|
43
|
+
metadatas: list[dict[str, Any]],
|
|
44
|
+
*,
|
|
45
|
+
embeddings: list[list[float]] | None = None,
|
|
46
|
+
embedder: EmbeddingProvider | None = None,
|
|
47
|
+
embed_model: str | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Add chunks to Chroma (embeddings required, or provide an embedder)."""
|
|
50
|
+
if embeddings is None:
|
|
51
|
+
if not embedder:
|
|
52
|
+
raise RuntimeError("ingest_chunks requires `embeddings=` or `embedder=`")
|
|
53
|
+
embeddings = embedder.embed(texts, model=embed_model)
|
|
54
|
+
coll = get_collection()
|
|
55
|
+
clean_metas = []
|
|
56
|
+
for m in metadatas:
|
|
57
|
+
clean = {}
|
|
58
|
+
for k, v in m.items():
|
|
59
|
+
if v is None:
|
|
60
|
+
continue
|
|
61
|
+
if isinstance(v, (str, int, float, bool)):
|
|
62
|
+
clean[k] = v
|
|
63
|
+
else:
|
|
64
|
+
clean[k] = str(v)
|
|
65
|
+
clean_metas.append(clean)
|
|
66
|
+
coll.add(ids=ids, embeddings=embeddings, documents=texts, metadatas=clean_metas)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def retrieve(
|
|
70
|
+
query_embedding: list[float],
|
|
71
|
+
top_k: int = 20,
|
|
72
|
+
where: dict | None = None,
|
|
73
|
+
) -> list[tuple[str, dict, float]]:
|
|
74
|
+
"""Return (document, metadata, distance) for top_k. Lower distance = more similar."""
|
|
75
|
+
coll = get_collection()
|
|
76
|
+
res = coll.query(
|
|
77
|
+
query_embeddings=[query_embedding],
|
|
78
|
+
n_results=top_k,
|
|
79
|
+
where=where,
|
|
80
|
+
include=["documents", "metadatas", "distances"],
|
|
81
|
+
)
|
|
82
|
+
docs = res["documents"][0] or []
|
|
83
|
+
metas = res["metadatas"][0] or []
|
|
84
|
+
dists = res["distances"][0] or []
|
|
85
|
+
return list(zip(docs, metas, dists))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def delete_all() -> None:
|
|
89
|
+
"""Remove all documents from the collection (for re-ingestion)."""
|
|
90
|
+
_get_client().delete_collection(COLLECTION_NAME)
|
|
91
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|