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.
@@ -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
@@ -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
+