ragup 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.
ragup/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from .core.document import Document
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ __all__ = [
6
+ "Document",
7
+ ]
ragup/api/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .server import RagUpServer
2
+
3
+ __all__ = [
4
+ "RagUpServer",
5
+ ]
ragup/api/routes.py ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ FastAPI routes.
3
+ """
4
+
5
+ from fastapi import APIRouter
6
+
7
+ router = APIRouter()
ragup/api/schemas.py ADDED
@@ -0,0 +1,15 @@
1
+ """
2
+ Pydantic request models.
3
+ """
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class SearchRequest(BaseModel):
9
+ query: str
10
+ top_k: int = 5
11
+
12
+
13
+ class AskRequest(BaseModel):
14
+ question: str
15
+ top_k: int = 5
ragup/api/server.py ADDED
@@ -0,0 +1,81 @@
1
+ """
2
+ FastAPI server for RagUp.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import uvicorn
8
+
9
+ from fastapi import FastAPI
10
+
11
+ from ..constants import (
12
+ DEFAULT_HOST,
13
+ DEFAULT_PORT,
14
+ )
15
+ from .schemas import (
16
+ AskRequest,
17
+ SearchRequest,
18
+ )
19
+
20
+
21
+ class RagUpServer:
22
+
23
+ def __init__(
24
+ self,
25
+ document,
26
+ api_key: str | None = None,
27
+ ):
28
+
29
+ self.document = document
30
+ self.api_key = api_key
31
+
32
+ self.app = FastAPI(
33
+ title="RagUp API",
34
+ version="1.0.0",
35
+ )
36
+
37
+ self._register_routes()
38
+
39
+ def _register_routes(self):
40
+
41
+ @self.app.get("/health")
42
+ def health():
43
+
44
+ return {
45
+ "status": "healthy"
46
+ }
47
+
48
+ @self.app.post("/search")
49
+ def search(request: SearchRequest):
50
+
51
+ return self.document.search(
52
+ query=request.query,
53
+ top_k=request.top_k,
54
+ )
55
+
56
+ if self.api_key:
57
+
58
+ @self.app.post("/ask")
59
+ def ask(request: AskRequest):
60
+
61
+ answer = self.document.ask(
62
+ question=request.question,
63
+ api_key=self.api_key,
64
+ top_k=request.top_k,
65
+ )
66
+
67
+ return {
68
+ "answer": answer
69
+ }
70
+
71
+ def run(
72
+ self,
73
+ host: str = DEFAULT_HOST,
74
+ port: int = DEFAULT_PORT,
75
+ ):
76
+
77
+ uvicorn.run(
78
+ self.app,
79
+ host=host,
80
+ port=port,
81
+ )
@@ -0,0 +1,9 @@
1
+
2
+
3
+ from .base import BaseChunker
4
+ from .recursive import RecursiveChunker
5
+
6
+ __all__ = [
7
+ "BaseChunker",
8
+ "RecursiveChunker",
9
+ ]
ragup/chunking/base.py ADDED
@@ -0,0 +1,36 @@
1
+ """
2
+ Base interface for all text chunkers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+
9
+
10
+ class BaseChunker(ABC):
11
+ """
12
+ Base class for all chunking strategies.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ chunk_size: int = 500,
18
+ overlap: int = 50,
19
+ ):
20
+ self.chunk_size = chunk_size
21
+ self.overlap = overlap
22
+
23
+ @abstractmethod
24
+ def split(self, text: str) -> list[str]:
25
+ """
26
+ Split text into chunks.
27
+
28
+ Parameters
29
+ ----------
30
+ text : str
31
+
32
+ Returns
33
+ -------
34
+ list[str]
35
+ """
36
+ raise NotImplementedError
@@ -0,0 +1,59 @@
1
+ """
2
+ Recursive text chunker.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from .base import BaseChunker
8
+
9
+
10
+ class RecursiveChunker(BaseChunker):
11
+ """
12
+ Splits text into overlapping chunks.
13
+
14
+ Chunk sizes are based on words rather than characters,
15
+ producing more natural chunks.
16
+ """
17
+
18
+ def split(self, text: str) -> list[str]:
19
+ """
20
+ Split text into overlapping chunks.
21
+
22
+ Parameters
23
+ ----------
24
+ text : str
25
+
26
+ Returns
27
+ -------
28
+ list[str]
29
+ """
30
+
31
+ if not text.strip():
32
+ return []
33
+
34
+ words = text.split()
35
+
36
+ if len(words) <= self.chunk_size:
37
+ return [" ".join(words)]
38
+
39
+ chunks = []
40
+
41
+ step = self.chunk_size - self.overlap
42
+
43
+ if step <= 0:
44
+ raise ValueError(
45
+ "overlap must be smaller than chunk_size"
46
+ )
47
+
48
+ for start in range(0, len(words), step):
49
+
50
+ end = start + self.chunk_size
51
+
52
+ chunk = words[start:end]
53
+
54
+ if not chunk:
55
+ continue
56
+
57
+ chunks.append(" ".join(chunk))
58
+
59
+ return chunks
ragup/config.py ADDED
@@ -0,0 +1,48 @@
1
+ """
2
+ Global configuration object for RagUp.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+ from .constants import (
9
+ DEFAULT_CHUNK_OVERLAP,
10
+ DEFAULT_CHUNK_SIZE,
11
+ DEFAULT_EMBEDDING_MODEL,
12
+ DEFAULT_TOP_K,
13
+ DEFAULT_SCORE_THRESHOLD,
14
+ DEFAULT_PROVIDER,
15
+ DEFAULT_MODEL,
16
+ DEFAULT_TEMPERATURE,
17
+ )
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class RagUpConfig:
22
+ """
23
+ Central configuration for the RagUp pipeline.
24
+ """
25
+
26
+ # Chunking
27
+ chunk_size: int = DEFAULT_CHUNK_SIZE
28
+ overlap: int = DEFAULT_CHUNK_OVERLAP
29
+
30
+ # Embeddings
31
+ embedding_model: str = DEFAULT_EMBEDDING_MODEL
32
+
33
+ # Search
34
+ top_k: int = DEFAULT_TOP_K
35
+ score_threshold: float = DEFAULT_SCORE_THRESHOLD
36
+
37
+ # LLM
38
+ provider: str = DEFAULT_PROVIDER
39
+ model: Optional[str] = DEFAULT_MODEL
40
+ api_key: Optional[str] = None
41
+ temperature: float = DEFAULT_TEMPERATURE
42
+
43
+ # Indexing
44
+ cache: bool = True
45
+ rebuild: bool = False
46
+
47
+ # Logging
48
+ verbose: bool = False
ragup/constants.py ADDED
@@ -0,0 +1,73 @@
1
+ """
2
+ Global constants used throughout RagUp.
3
+ """
4
+
5
+ from pathlib import Path
6
+
7
+ # ==========================================
8
+ # Package
9
+ # ==========================================
10
+
11
+ PACKAGE_NAME = "ragup"
12
+ PACKAGE_VERSION = "1.0.0"
13
+
14
+ # ==========================================
15
+ # Supported Files
16
+ # ==========================================
17
+
18
+ SUPPORTED_EXTENSIONS = {
19
+ ".pdf",
20
+ ".txt",
21
+ ".json",
22
+ }
23
+
24
+ # ==========================================
25
+ # Chunking Defaults
26
+ # ==========================================
27
+
28
+ DEFAULT_CHUNK_SIZE = 500
29
+ DEFAULT_CHUNK_OVERLAP = 50
30
+
31
+ # ==========================================
32
+ # Embedding Defaults
33
+ # ==========================================
34
+
35
+ DEFAULT_EMBEDDING_MODEL = "all-MiniLM-L6-v2"
36
+
37
+ # ==========================================
38
+ # Vector Database
39
+ # ==========================================
40
+
41
+ DEFAULT_RAGUP_DIR = Path(".ragup")
42
+
43
+ DEFAULT_VECTOR_DB_DIR = DEFAULT_RAGUP_DIR / "db"
44
+
45
+ DEFAULT_METADATA_FILE = DEFAULT_RAGUP_DIR / "metadata.json"
46
+
47
+ DEFAULT_COLLECTION_NAME = "ragup_collection"
48
+
49
+ # ==========================================
50
+ # Search Defaults
51
+ # ==========================================
52
+
53
+ DEFAULT_TOP_K = 5
54
+
55
+ DEFAULT_SCORE_THRESHOLD = 0.0
56
+
57
+ # ==========================================
58
+ # FastAPI Defaults
59
+ # ==========================================
60
+
61
+ DEFAULT_HOST = "127.0.0.1"
62
+
63
+ DEFAULT_PORT = 8085
64
+
65
+ # ==========================================
66
+ # LLM Defaults
67
+ # ==========================================
68
+
69
+ DEFAULT_TEMPERATURE = 0.0
70
+
71
+ DEFAULT_PROVIDER = "gemini"
72
+
73
+ DEFAULT_MODEL = None
ragup/core/__init__.py ADDED
File without changes
ragup/core/document.py ADDED
@@ -0,0 +1,282 @@
1
+ """
2
+ Core Document class for RagUp.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ import json
7
+ from pathlib import Path
8
+ from ..api import RagUpServer
9
+ from ..chunking import RecursiveChunker
10
+ from ..config import RagUpConfig
11
+ from ..embeddings import Embedder
12
+ from ..exceptions import FileNotIndexedError
13
+ from ..loaders import get_loader
14
+ from ..retrieval import Retriever, format_search_results
15
+ from ..vectordb import VectorDBManager
16
+ from ..llm import generate_answer
17
+ from ..constants import (
18
+ DEFAULT_METADATA_FILE,
19
+ )
20
+ class Document:
21
+ """
22
+ Main entry point for RagUp.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ file_path: str | Path,
28
+ ):
29
+
30
+ self.file_path = Path(file_path)
31
+
32
+ self.config = RagUpConfig()
33
+
34
+ self.embedder = Embedder(
35
+ self.config.embedding_model
36
+ )
37
+
38
+ self.chunker = RecursiveChunker(
39
+ chunk_size=self.config.chunk_size,
40
+ overlap=self.config.overlap,
41
+ )
42
+
43
+ self.vectordb = VectorDBManager()
44
+
45
+ self.retriever = Retriever(
46
+ self.embedder,
47
+ self.vectordb,
48
+ )
49
+
50
+ self._indexed = False
51
+
52
+ def _load_document(self) -> str:
53
+ """
54
+ Load the document.
55
+ """
56
+
57
+ loader = get_loader(self.file_path)
58
+
59
+ return loader.load()
60
+
61
+ def _chunk_document(
62
+ self,
63
+ text: str,
64
+ ) -> list[str]:
65
+ """
66
+ Split the document into chunks.
67
+ """
68
+
69
+ return self.chunker.split(text)
70
+
71
+ def _embed_chunks(
72
+ self,
73
+ chunks: list[str],
74
+ ) -> list[list[float]]:
75
+ """
76
+ Generate embeddings for chunks.
77
+ """
78
+
79
+ return self.embedder.embed_documents(
80
+ chunks
81
+ )
82
+
83
+ def _build_metadata(
84
+ self,
85
+ chunks: list[str],
86
+ ) -> tuple[list[str], list[dict]]:
87
+ """
88
+ Build chunk ids and metadata.
89
+ """
90
+
91
+ stem = self.file_path.stem
92
+
93
+ ids = []
94
+ metadatas = []
95
+
96
+ for index in range(len(chunks)):
97
+
98
+ ids.append(
99
+ f"{stem}_{index + 1:04d}"
100
+ )
101
+
102
+ metadatas.append(
103
+ {
104
+ "source": self.file_path.name,
105
+ "chunk": index + 1,
106
+ }
107
+ )
108
+
109
+ return ids, metadatas
110
+
111
+ def _metadata(self) -> dict:
112
+
113
+ return {
114
+ "source": str(self.file_path.resolve()),
115
+ "last_modified": self.file_path.stat().st_mtime,
116
+ "chunk_size": self.chunker.chunk_size,
117
+ "overlap": self.chunker.overlap,
118
+ "embedding_model": self.config.embedding_model,
119
+ }
120
+
121
+
122
+ def _save_metadata(self) -> None:
123
+
124
+ DEFAULT_METADATA_FILE.parent.mkdir(
125
+ exist_ok=True
126
+ )
127
+
128
+ with open(
129
+ DEFAULT_METADATA_FILE,
130
+ "w",
131
+ encoding="utf-8",
132
+ ) as f:
133
+
134
+ json.dump(
135
+ self._metadata(),
136
+ f,
137
+ indent=4,
138
+ )
139
+
140
+
141
+ def _is_cached(self) -> bool:
142
+
143
+ if not DEFAULT_METADATA_FILE.exists():
144
+ return False
145
+
146
+ with open(
147
+ DEFAULT_METADATA_FILE,
148
+ "r",
149
+ encoding="utf-8",
150
+ ) as f:
151
+
152
+ metadata = json.load(f)
153
+
154
+ return metadata == self._metadata()
155
+
156
+ def _ensure_index(self) -> None:
157
+ """
158
+ Ensure that an index exists for this document.
159
+ """
160
+
161
+ if self._indexed:
162
+ return
163
+
164
+ if self._is_cached():
165
+ self._indexed = True
166
+ return
167
+
168
+ raise FileNotIndexedError(
169
+ "No RagUp index found for this document. Run doc.ragup() first."
170
+ )
171
+
172
+ def _clear_index(self) -> None:
173
+ """
174
+ Remove the existing index before rebuilding.
175
+ """
176
+
177
+ self.vectordb.reset()
178
+
179
+ def ragup(
180
+ self,
181
+ chunk_size: int | None = None,
182
+ overlap: int | None = None,
183
+ ) -> None:
184
+ """
185
+ Build or load the RagUp index.
186
+ """
187
+
188
+ if chunk_size is not None:
189
+ self.chunker.chunk_size = chunk_size
190
+
191
+ if overlap is not None:
192
+ self.chunker.overlap = overlap
193
+
194
+ if self._is_cached():
195
+ print("✅ Using cached index.")
196
+ self._indexed = True
197
+
198
+ return
199
+ print("🔄 Building new index...")
200
+ self._clear_index()
201
+
202
+ text = self._load_document()
203
+
204
+ chunks = self._chunk_document(text)
205
+
206
+ embeddings = self._embed_chunks(chunks)
207
+
208
+ ids, metadatas = self._build_metadata(chunks)
209
+
210
+ self.vectordb.add(
211
+ ids=ids,
212
+ documents=chunks,
213
+ embeddings=embeddings,
214
+ metadatas=metadatas,
215
+ )
216
+
217
+ self._save_metadata()
218
+
219
+ self._indexed = True
220
+
221
+
222
+ def search(
223
+ self,
224
+ query: str,
225
+ top_k: int = 5,
226
+ ) -> list[dict]:
227
+ """
228
+ Perform semantic search on the indexed document.
229
+ """
230
+
231
+ self._ensure_index()
232
+
233
+ results = self.retriever.retrieve(
234
+ query=query,
235
+ top_k=top_k,
236
+ )
237
+
238
+ return format_search_results(results)
239
+ def ask(
240
+ self,
241
+ question: str,
242
+ api_key: str,
243
+ top_k: int = 5,
244
+ ) -> str:
245
+ """
246
+ Answer a question using the indexed document.
247
+ """
248
+
249
+ self._ensure_index()
250
+
251
+ context = self.search(
252
+ query=question,
253
+ top_k=top_k,
254
+ )
255
+
256
+ return generate_answer(
257
+ question=question,
258
+ context=context,
259
+ api_key=api_key,
260
+ )
261
+
262
+ def serve(
263
+ self,
264
+ api_key: str | None = None,
265
+ host: str = "127.0.0.1",
266
+ port: int = 8085,
267
+ ) -> None:
268
+ """
269
+ Launch the RagUp FastAPI server.
270
+ """
271
+
272
+ self._ensure_index()
273
+
274
+ server = RagUpServer(
275
+ document=self,
276
+ api_key=api_key,
277
+ )
278
+
279
+ server.run(
280
+ host=host,
281
+ port=port,
282
+ )
@@ -0,0 +1,9 @@
1
+ """
2
+ Embedding module.
3
+ """
4
+
5
+ from .embedder import Embedder
6
+
7
+ __all__ = [
8
+ "Embedder",
9
+ ]
@@ -0,0 +1,61 @@
1
+ """
2
+ Sentence Transformer embedder.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from sentence_transformers import SentenceTransformer
8
+
9
+ from .models import (
10
+ DEFAULT_EMBEDDING_MODEL,
11
+ get_model_name,
12
+ )
13
+
14
+
15
+ class Embedder:
16
+ """
17
+ Wrapper around SentenceTransformer.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ model_name: str = DEFAULT_EMBEDDING_MODEL,
23
+ ):
24
+
25
+ self.model_name = get_model_name(model_name)
26
+
27
+ self.model = SentenceTransformer(
28
+ self.model_name
29
+ )
30
+
31
+ def embed_documents(
32
+ self,
33
+ documents: list[str],
34
+ ) -> list[list[float]]:
35
+ """
36
+ Generate embeddings for document chunks.
37
+ """
38
+
39
+ embeddings = self.model.encode(
40
+ documents,
41
+ convert_to_numpy=True,
42
+ normalize_embeddings=True,
43
+ )
44
+
45
+ return embeddings.tolist()
46
+
47
+ def embed_query(
48
+ self,
49
+ query: str,
50
+ ) -> list[float]:
51
+ """
52
+ Generate an embedding for a query.
53
+ """
54
+
55
+ embedding = self.model.encode(
56
+ query,
57
+ convert_to_numpy=True,
58
+ normalize_embeddings=True,
59
+ )
60
+
61
+ return embedding.tolist()