ragkit-dev 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_kit/__init__.py +44 -0
- rag_kit/__main__.py +5 -0
- rag_kit/api.py +155 -0
- rag_kit/cli.py +484 -0
- rag_kit/env.py +84 -0
- rag_kit/errors.py +37 -0
- rag_kit/log.py +42 -0
- rag_kit/pdf_font_repair.py +132 -0
- rag_kit/pipeline.py +169 -0
- rag_kit/py.typed +0 -0
- rag_kit/settings.py +247 -0
- rag_kit/step01_load_pdf.py +38 -0
- rag_kit/step02_extract_text.py +138 -0
- rag_kit/step03_clean_text.py +76 -0
- rag_kit/step04_chunking.py +36 -0
- rag_kit/step05_embedding.py +77 -0
- rag_kit/step06_vector_store.py +251 -0
- rag_kit/step08_query_embedding.py +17 -0
- rag_kit/step09_retrieve.py +34 -0
- rag_kit/step10_generate.py +84 -0
- rag_kit/step11_answer.py +60 -0
- rag_kit/ui/__init__.py +1 -0
- rag_kit/ui/app.py +153 -0
- ragkit_dev-0.1.0.dist-info/METADATA +448 -0
- ragkit_dev-0.1.0.dist-info/RECORD +28 -0
- ragkit_dev-0.1.0.dist-info/WHEEL +4 -0
- ragkit_dev-0.1.0.dist-info/entry_points.txt +2 -0
- ragkit_dev-0.1.0.dist-info/licenses/LICENSE +21 -0
rag_kit/settings.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""All tunable settings as one immutable object.
|
|
2
|
+
|
|
3
|
+
settings = Settings.load(project_dir) # project_dir/.env + environment
|
|
4
|
+
settings = Settings.load() # find the project (see below)
|
|
5
|
+
pipeline = Pipeline(settings=settings)
|
|
6
|
+
|
|
7
|
+
A project is a folder containing a `rag-kit.toml` marker file. Without an
|
|
8
|
+
explicit project_dir, it is $RAG_PROJECT_DIR, else the nearest folder at or
|
|
9
|
+
above the current directory that has the marker; if there is none, loading
|
|
10
|
+
fails instead of guessing (a guess would scatter Qdrant stores around).
|
|
11
|
+
|
|
12
|
+
`Settings.load` reads the project's `.env` without touching `os.environ`, and
|
|
13
|
+
real environment variables win over `.env`. Relative paths in it (`QDRANT_PATH`,
|
|
14
|
+
`PDF_PATH`) are resolved against the project directory - never against the
|
|
15
|
+
current working directory - so running a command from somewhere else cannot
|
|
16
|
+
silently point at a different, empty Qdrant store.
|
|
17
|
+
|
|
18
|
+
Every function that needs settings takes an optional `settings=`; without one
|
|
19
|
+
it uses `default_settings()`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import dataclasses
|
|
23
|
+
import os
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Mapping, Optional
|
|
27
|
+
|
|
28
|
+
from dotenv import dotenv_values
|
|
29
|
+
|
|
30
|
+
from rag_kit.errors import ConfigError
|
|
31
|
+
|
|
32
|
+
SUPPORTED_EXTENSIONS = (".pdf", ".txt", ".md")
|
|
33
|
+
|
|
34
|
+
# Every variable Settings.load() reads. rag_kit.env never exports these to
|
|
35
|
+
# os.environ (see rag_kit/env.py); RAG_* control variables are excluded there too.
|
|
36
|
+
SETTING_NAMES = frozenset({
|
|
37
|
+
"PDF_PATH", "PDF_FONT_REPAIR", "CHUNK_SIZE", "CHUNK_OVERLAP", "GOOGLE_API_KEY",
|
|
38
|
+
"EMBEDDING_PROVIDER", "EMBEDDING_MODEL", "EMBEDDING_DIM", "EMBED_BATCH_SIZE", "EMBED_SLEEP",
|
|
39
|
+
"QDRANT_URL", "QDRANT_API_KEY", "QDRANT_PATH", "COLLECTION_NAME", "TOP_K", "MIN_SCORE",
|
|
40
|
+
"LLM_MODEL", "TEMPERATURE",
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
# Marks a project folder. Only its presence matters for now; the contents are
|
|
44
|
+
# reserved for future settings.
|
|
45
|
+
PROJECT_MARKER = "rag-kit.toml"
|
|
46
|
+
|
|
47
|
+
_PACKAGE_DIR = Path(__file__).resolve().parent
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def find_project_dir(start=None, environ: Optional[Mapping[str, str]] = None) -> Path:
|
|
51
|
+
"""$RAG_PROJECT_DIR if set, else the nearest folder at or above `start`
|
|
52
|
+
(default: the current directory) that contains rag-kit.toml."""
|
|
53
|
+
environ = os.environ if environ is None else environ
|
|
54
|
+
if environ.get("RAG_PROJECT_DIR"):
|
|
55
|
+
return _checked_project_dir(environ["RAG_PROJECT_DIR"], "RAG_PROJECT_DIR")
|
|
56
|
+
|
|
57
|
+
start = Path(start or os.getcwd()).resolve()
|
|
58
|
+
for folder in (start, *start.parents):
|
|
59
|
+
if (folder / PROJECT_MARKER).is_file():
|
|
60
|
+
return _checked_project_dir(folder, "the project marker")
|
|
61
|
+
raise ConfigError(
|
|
62
|
+
f"No rag-kit project found: neither {start} nor any folder above it contains "
|
|
63
|
+
f"{PROJECT_MARKER}. Run the command inside a project, pass --project DIR, or set "
|
|
64
|
+
"RAG_PROJECT_DIR."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _checked_project_dir(value, source: str) -> Path:
|
|
69
|
+
path = Path(value).expanduser().resolve()
|
|
70
|
+
if not path.is_dir():
|
|
71
|
+
raise ConfigError(f"Project folder from {source} does not exist: {path}")
|
|
72
|
+
if path == _PACKAGE_DIR or _PACKAGE_DIR in path.parents:
|
|
73
|
+
raise ConfigError(
|
|
74
|
+
f"Project folder {path} is inside the installed rag-kit package. Keep documents, "
|
|
75
|
+
".env and Qdrant data in your own project folder."
|
|
76
|
+
)
|
|
77
|
+
return path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _resolve(project_dir: Path, value) -> Path:
|
|
81
|
+
"""Absolute paths stay as they are; relative ones are taken from project_dir."""
|
|
82
|
+
path = Path(value).expanduser()
|
|
83
|
+
return path if path.is_absolute() else (project_dir / path).resolve()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _Reader:
|
|
87
|
+
"""Typed access to merged .env + environment values. Empty means unset."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, values: Mapping[str, Optional[str]]):
|
|
90
|
+
self.values = values
|
|
91
|
+
|
|
92
|
+
def text(self, name: str, default: Optional[str] = None) -> Optional[str]:
|
|
93
|
+
return self.values.get(name) or default
|
|
94
|
+
|
|
95
|
+
def integer(self, name: str, default: str) -> int:
|
|
96
|
+
raw = self.text(name, default)
|
|
97
|
+
try:
|
|
98
|
+
return int(raw)
|
|
99
|
+
except ValueError:
|
|
100
|
+
raise ConfigError(f"{name} must be a whole number, got {raw!r}.") from None
|
|
101
|
+
|
|
102
|
+
def number(self, name: str, default: str) -> float:
|
|
103
|
+
raw = self.text(name, default)
|
|
104
|
+
try:
|
|
105
|
+
return float(raw)
|
|
106
|
+
except ValueError:
|
|
107
|
+
raise ConfigError(f"{name} must be a number, got {raw!r}.") from None
|
|
108
|
+
|
|
109
|
+
def flag(self, name: str, default: str) -> bool:
|
|
110
|
+
return self.text(name, default).strip().lower() in {"1", "true", "yes", "on"}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class Settings:
|
|
115
|
+
project_dir: Path
|
|
116
|
+
env_file: Path
|
|
117
|
+
|
|
118
|
+
# --- Step 1-2: input document and extraction ----------------------------
|
|
119
|
+
# Default file for `ingest` when no path is given on the command line.
|
|
120
|
+
pdf_path: Path
|
|
121
|
+
# Repairs PDFs whose subset fonts have no ToUnicode map (see
|
|
122
|
+
# rag_kit/pdf_font_repair.py). Off by default: ordinary PDFs do not need it.
|
|
123
|
+
pdf_font_repair: bool = False
|
|
124
|
+
|
|
125
|
+
# --- Step 4: chunking ---------------------------------------------------
|
|
126
|
+
chunk_size: int = 1000
|
|
127
|
+
chunk_overlap: int = 150
|
|
128
|
+
|
|
129
|
+
# --- Step 5: embedding --------------------------------------------------
|
|
130
|
+
google_api_key: Optional[str] = field(default=None, repr=False)
|
|
131
|
+
# "huggingface" runs a local model - no API key, no quota, no cost.
|
|
132
|
+
# "google" uses Gemini embeddings (needs a real API key with embedding quota).
|
|
133
|
+
embedding_provider: str = "huggingface"
|
|
134
|
+
embedding_model: str = "BAAI/bge-m3"
|
|
135
|
+
# None => detect the real dimension at runtime instead of hard-coding it.
|
|
136
|
+
embedding_dim: Optional[int] = None
|
|
137
|
+
embed_batch_size: int = 90
|
|
138
|
+
# Gemini free tier allows ~100 embedded texts / minute; pause between batches
|
|
139
|
+
# so a large PDF does not trip a 429. Not needed for a local model.
|
|
140
|
+
embed_sleep: float = 0.0
|
|
141
|
+
|
|
142
|
+
# --- Step 6: Qdrant -----------------------------------------------------
|
|
143
|
+
qdrant_url: str = "http://localhost:6333"
|
|
144
|
+
qdrant_api_key: Optional[str] = field(default=None, repr=False)
|
|
145
|
+
# Local on-disk mode, no server. Takes priority over qdrant_url when set.
|
|
146
|
+
qdrant_path: Optional[Path] = None
|
|
147
|
+
collection_name: str = "pdf_rag"
|
|
148
|
+
|
|
149
|
+
# --- Step 9: retrieval --------------------------------------------------
|
|
150
|
+
top_k: int = 4
|
|
151
|
+
# Chunks scoring below this are treated as "not relevant". None keeps every hit.
|
|
152
|
+
min_score: Optional[float] = None
|
|
153
|
+
|
|
154
|
+
# --- Step 10: Gemini LLM ------------------------------------------------
|
|
155
|
+
llm_model: str = "gemini-2.5-flash"
|
|
156
|
+
temperature: float = 0.2
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def load(cls, project_dir=None, env_file=None,
|
|
160
|
+
environ: Optional[Mapping[str, str]] = None) -> "Settings":
|
|
161
|
+
"""Read `env_file` (default: $RAG_ENV_FILE, else project_dir/.env) and
|
|
162
|
+
overlay `environ` (default: os.environ). Neither is modified.
|
|
163
|
+
|
|
164
|
+
Without project_dir the project is found with find_project_dir()."""
|
|
165
|
+
environ = os.environ if environ is None else environ
|
|
166
|
+
if project_dir is None:
|
|
167
|
+
project_dir = find_project_dir(environ=environ)
|
|
168
|
+
else:
|
|
169
|
+
project_dir = _checked_project_dir(project_dir, "project_dir")
|
|
170
|
+
env_file = Path(env_file or environ.get("RAG_ENV_FILE") or (project_dir / ".env"))
|
|
171
|
+
values = {**dotenv_values(env_file), **environ} # environment wins, as before
|
|
172
|
+
env = _Reader(values)
|
|
173
|
+
|
|
174
|
+
provider = env.text("EMBEDDING_PROVIDER", "huggingface").lower()
|
|
175
|
+
qdrant_path = env.text("QDRANT_PATH")
|
|
176
|
+
min_score = env.text("MIN_SCORE")
|
|
177
|
+
return cls(
|
|
178
|
+
project_dir=project_dir,
|
|
179
|
+
env_file=env_file,
|
|
180
|
+
pdf_path=_resolve(project_dir, env.text("PDF_PATH", "data/sample.pdf")),
|
|
181
|
+
pdf_font_repair=env.flag("PDF_FONT_REPAIR", "false"),
|
|
182
|
+
chunk_size=env.integer("CHUNK_SIZE", "1000"),
|
|
183
|
+
chunk_overlap=env.integer("CHUNK_OVERLAP", "150"),
|
|
184
|
+
google_api_key=env.text("GOOGLE_API_KEY"),
|
|
185
|
+
embedding_provider=provider,
|
|
186
|
+
embedding_model=env.text("EMBEDDING_MODEL", "BAAI/bge-m3" if provider == "huggingface"
|
|
187
|
+
else "models/gemini-embedding-001"),
|
|
188
|
+
embedding_dim=env.integer("EMBEDDING_DIM", "0") or None,
|
|
189
|
+
embed_batch_size=env.integer("EMBED_BATCH_SIZE", "90"),
|
|
190
|
+
embed_sleep=env.number("EMBED_SLEEP", "60" if provider == "google" else "0"),
|
|
191
|
+
qdrant_url=env.text("QDRANT_URL", "http://localhost:6333"),
|
|
192
|
+
qdrant_api_key=env.text("QDRANT_API_KEY"),
|
|
193
|
+
qdrant_path=_resolve(project_dir, qdrant_path) if qdrant_path else None,
|
|
194
|
+
collection_name=env.text("COLLECTION_NAME", "pdf_rag"),
|
|
195
|
+
top_k=env.integer("TOP_K", "4"),
|
|
196
|
+
min_score=env.number("MIN_SCORE", "0") if min_score else None,
|
|
197
|
+
llm_model=env.text("LLM_MODEL", "gemini-2.5-flash"),
|
|
198
|
+
temperature=env.number("TEMPERATURE", "0.2"),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def replace(self, **changes) -> "Settings":
|
|
202
|
+
return dataclasses.replace(self, **changes)
|
|
203
|
+
|
|
204
|
+
def resolve_path(self, value) -> Path:
|
|
205
|
+
return _resolve(self.project_dir, value)
|
|
206
|
+
|
|
207
|
+
def require_api_key(self) -> None:
|
|
208
|
+
if not self.google_api_key:
|
|
209
|
+
raise ConfigError(
|
|
210
|
+
f"GOOGLE_API_KEY is not set. Copy .env.example to {self.env_file} and put your "
|
|
211
|
+
"Gemini API key there (https://aistudio.google.com/app/apikey)."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def validate(self) -> None:
|
|
215
|
+
"""Fail early on settings that would only break halfway through a run."""
|
|
216
|
+
if self.embedding_provider not in {"huggingface", "google"}:
|
|
217
|
+
raise ConfigError(
|
|
218
|
+
"EMBEDDING_PROVIDER must be 'huggingface' or 'google', "
|
|
219
|
+
f"got {self.embedding_provider!r}."
|
|
220
|
+
)
|
|
221
|
+
if self.chunk_size <= 0 or not 0 <= self.chunk_overlap < self.chunk_size:
|
|
222
|
+
raise ConfigError(
|
|
223
|
+
f"Need CHUNK_SIZE > 0 and 0 <= CHUNK_OVERLAP < CHUNK_SIZE "
|
|
224
|
+
f"(got {self.chunk_size} / {self.chunk_overlap})."
|
|
225
|
+
)
|
|
226
|
+
if self.top_k <= 0:
|
|
227
|
+
raise ConfigError(f"TOP_K must be at least 1, got {self.top_k}.")
|
|
228
|
+
if self.embedding_provider == "google":
|
|
229
|
+
self.require_api_key()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
_default: Optional[Settings] = None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def set_default_settings(settings: Settings) -> None:
|
|
236
|
+
"""Settings used by functions that are not given any."""
|
|
237
|
+
global _default
|
|
238
|
+
_default = settings
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def default_settings() -> Settings:
|
|
242
|
+
"""The settings registered with set_default_settings(); if none were, the
|
|
243
|
+
project found by find_project_dir() is loaded once and kept."""
|
|
244
|
+
global _default
|
|
245
|
+
if _default is None:
|
|
246
|
+
_default = Settings.load()
|
|
247
|
+
return _default
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""STEP 1 - Input document.
|
|
2
|
+
|
|
3
|
+
Validate the file before anything else, so a wrong path fails here
|
|
4
|
+
instead of halfway through the pipeline. PDF, plain text and Markdown are
|
|
5
|
+
supported (see rag_kit.settings.SUPPORTED_EXTENSIONS).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rag_kit.errors import UnsupportedFileError
|
|
12
|
+
from rag_kit.settings import SUPPORTED_EXTENSIONS
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_document(path) -> Path:
|
|
18
|
+
path = Path(path).expanduser().resolve()
|
|
19
|
+
|
|
20
|
+
if not path.exists():
|
|
21
|
+
raise UnsupportedFileError(f"File not found: {path}")
|
|
22
|
+
if not path.is_file():
|
|
23
|
+
raise UnsupportedFileError(f"Not a file: {path}")
|
|
24
|
+
if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
|
25
|
+
raise UnsupportedFileError(
|
|
26
|
+
f"Unsupported file type '{path.suffix or '(none)'}' for {path.name}. "
|
|
27
|
+
f"Supported: {', '.join(SUPPORTED_EXTENSIONS)}."
|
|
28
|
+
)
|
|
29
|
+
if path.stat().st_size == 0:
|
|
30
|
+
raise UnsupportedFileError(f"File is empty: {path.name}")
|
|
31
|
+
|
|
32
|
+
size_kb = path.stat().st_size / 1024
|
|
33
|
+
logger.info(f"[1] Document : {path.name} ({size_kb:.1f} KB)")
|
|
34
|
+
return path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Kept for scripts written against the original PDF-only pipeline.
|
|
38
|
+
load_pdf = load_document
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""STEP 2 - Text extraction.
|
|
2
|
+
|
|
3
|
+
PDFs become one LangChain Document per page, with the 1-based page number in
|
|
4
|
+
metadata - that number is what step 11 cites. Text and Markdown files become a
|
|
5
|
+
single Document with no page number.
|
|
6
|
+
|
|
7
|
+
Every Document carries the same metadata keys:
|
|
8
|
+
source_file file name, used to replace / delete a document later
|
|
9
|
+
doc_id content hash, changes when the file changes
|
|
10
|
+
file_type "pdf", "txt" or "md"
|
|
11
|
+
page 0-based page index (None for text files)
|
|
12
|
+
page_number 1-based page number (None for text files)
|
|
13
|
+
|
|
14
|
+
Optional font repair (PDF_FONT_REPAIR=true or font_repair=True): some PDFs embed
|
|
15
|
+
subset fonts with no ToUnicode map, and every extractor then returns scrambled
|
|
16
|
+
glyph codes like `& ? ! & &`. `pdf_font_repair.repair_page` undoes the specific
|
|
17
|
+
substitution cipher of this project's grammar PDF. Leave it off for ordinary
|
|
18
|
+
PDFs.
|
|
19
|
+
|
|
20
|
+
After extraction every PDF page is scored (alphabetic ratio + English-word
|
|
21
|
+
ratio). Pages that still look like garbage are reported, not silently embedded -
|
|
22
|
+
that is the point where OCR would be the next step.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import logging
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import List, Optional
|
|
29
|
+
|
|
30
|
+
from langchain_core.documents import Document
|
|
31
|
+
|
|
32
|
+
from rag_kit.errors import ExtractionError
|
|
33
|
+
from rag_kit.pdf_font_repair import is_readable, repair_page, text_quality
|
|
34
|
+
from rag_kit.settings import Settings, default_settings
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def document_id(path: Path) -> str:
|
|
40
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def extract_text(path: Path, font_repair: Optional[bool] = None,
|
|
44
|
+
settings: Optional[Settings] = None) -> List[Document]:
|
|
45
|
+
path = Path(path)
|
|
46
|
+
if path.suffix.lower() == ".pdf":
|
|
47
|
+
if font_repair is None:
|
|
48
|
+
font_repair = (settings or default_settings()).pdf_font_repair
|
|
49
|
+
return _extract_pdf(path, font_repair)
|
|
50
|
+
return _extract_plain_text(path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _metadata(path: Path, doc_id: str, page: Optional[int]) -> dict:
|
|
54
|
+
return {
|
|
55
|
+
"source_file": path.name,
|
|
56
|
+
"doc_id": doc_id,
|
|
57
|
+
"file_type": path.suffix.lower().lstrip("."),
|
|
58
|
+
"page": page,
|
|
59
|
+
"page_number": None if page is None else page + 1,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _extract_plain_text(path: Path) -> List[Document]:
|
|
64
|
+
try:
|
|
65
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
66
|
+
except UnicodeDecodeError as exc:
|
|
67
|
+
raise ExtractionError(
|
|
68
|
+
f"{path.name} is not valid UTF-8 text ({exc.reason} at byte {exc.start}). "
|
|
69
|
+
"Re-save it as UTF-8 and ingest again."
|
|
70
|
+
) from exc
|
|
71
|
+
except OSError as exc:
|
|
72
|
+
raise ExtractionError(f"Could not read {path.name}: {exc}") from exc
|
|
73
|
+
|
|
74
|
+
logger.info(f"[2] Extracted : 1 text document, {len(text):,} characters")
|
|
75
|
+
return [Document(page_content=text, metadata=_metadata(path, document_id(path), None))]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _extract_pdf(path: Path, font_repair: bool) -> List[Document]:
|
|
79
|
+
import pymupdf
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
doc = pymupdf.open(str(path))
|
|
83
|
+
except Exception as exc: # pymupdf raises several types for broken files
|
|
84
|
+
raise ExtractionError(f"Could not open {path.name} as a PDF: {exc}") from exc
|
|
85
|
+
|
|
86
|
+
if doc.needs_pass:
|
|
87
|
+
doc.close()
|
|
88
|
+
raise ExtractionError(
|
|
89
|
+
f"{path.name} is password-protected. Decrypt it first (e.g. `qpdf --decrypt`)."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
doc_id = document_id(path)
|
|
93
|
+
pages: List[Document] = []
|
|
94
|
+
readable = corrupted = blank = 0
|
|
95
|
+
worst: List[tuple] = []
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
for index in range(doc.page_count):
|
|
99
|
+
page = doc[index]
|
|
100
|
+
text = repair_page(page) if font_repair else page.get_text("text")
|
|
101
|
+
page_number = index + 1
|
|
102
|
+
|
|
103
|
+
if len(text.strip()) < 20:
|
|
104
|
+
blank += 1
|
|
105
|
+
elif is_readable(text):
|
|
106
|
+
readable += 1
|
|
107
|
+
else:
|
|
108
|
+
corrupted += 1
|
|
109
|
+
alpha_ratio, word_ratio = text_quality(text)
|
|
110
|
+
worst.append((round(word_ratio, 2), round(alpha_ratio, 2), page_number))
|
|
111
|
+
|
|
112
|
+
pages.append(Document(page_content=text, metadata=_metadata(path, doc_id, index)))
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
raise ExtractionError(f"Text extraction failed on {path.name}: {exc}") from exc
|
|
115
|
+
finally:
|
|
116
|
+
doc.close()
|
|
117
|
+
|
|
118
|
+
total_chars = sum(len(p.page_content) for p in pages)
|
|
119
|
+
logger.info(f"[2] Extracted : {len(pages)} pages, {total_chars:,} characters"
|
|
120
|
+
f"{' (font repair on)' if font_repair else ''}")
|
|
121
|
+
logger.info(f" quality : {readable} readable, {corrupted} corrupted, {blank} blank")
|
|
122
|
+
|
|
123
|
+
if corrupted:
|
|
124
|
+
worst.sort()
|
|
125
|
+
preview = ", ".join(f"p{pn}" for _, _, pn in worst[:10])
|
|
126
|
+
logger.warning(f" ! {corrupted} page(s) look corrupted: {preview}")
|
|
127
|
+
if font_repair:
|
|
128
|
+
logger.warning(" The maps in pdf_font_repair.py are PDF-specific; "
|
|
129
|
+
"OCR is the fallback.")
|
|
130
|
+
else:
|
|
131
|
+
logger.warning(" If the text is scrambled glyphs, try PDF_FONT_REPAIR=true; "
|
|
132
|
+
"otherwise add OCR (pytesseract / ocrmypdf).")
|
|
133
|
+
|
|
134
|
+
if total_chars < 100:
|
|
135
|
+
logger.warning(" ! Almost no text found - this PDF is probably a scan. Add OCR "
|
|
136
|
+
"before this pipeline can work.")
|
|
137
|
+
|
|
138
|
+
return pages
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""STEP 3 - Text cleaning.
|
|
2
|
+
|
|
3
|
+
PDF text arrives with hard line breaks, hyphen-split words, repeated
|
|
4
|
+
headers/footers and stray page numbers. All of that pollutes the embeddings,
|
|
5
|
+
so it gets removed before chunking.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
from langchain_core.documents import Document
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
|
|
18
|
+
_HYPHEN_BREAK = re.compile(r"(\w)-\n(\w)")
|
|
19
|
+
_SINGLE_NEWLINE = re.compile(r"(?<![.!?:;।])\n(?![\n\-\u2022\d])")
|
|
20
|
+
_PAGE_NUMBER_LINE = re.compile(r"^\s*(page\s*)?[\divxlIVXL]{1,4}\s*$", re.MULTILINE | re.IGNORECASE)
|
|
21
|
+
_SPACES = re.compile(r"[ \t]{2,}")
|
|
22
|
+
_BLANK_LINES = re.compile(r"\n{3,}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def clean_text(text: str) -> str:
|
|
26
|
+
text = text.replace("\u00ad", "") # soft hyphen
|
|
27
|
+
text = text.replace("\ufeff", "") # BOM
|
|
28
|
+
text = _CONTROL_CHARS.sub(" ", text)
|
|
29
|
+
text = _HYPHEN_BREAK.sub(r"\1\2", text) # "informa-\ntion" -> "information"
|
|
30
|
+
text = _PAGE_NUMBER_LINE.sub("", text)
|
|
31
|
+
text = _SINGLE_NEWLINE.sub(" ", text) # unwrap mid-sentence line breaks
|
|
32
|
+
text = _SPACES.sub(" ", text)
|
|
33
|
+
text = _BLANK_LINES.sub("\n\n", text)
|
|
34
|
+
return text.strip()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _find_repeating_lines(docs: List[Document], threshold: float = 0.6) -> set:
|
|
38
|
+
"""Lines that show up on most pages are headers/footers, not content."""
|
|
39
|
+
if len(docs) < 4:
|
|
40
|
+
return set()
|
|
41
|
+
|
|
42
|
+
counter = Counter()
|
|
43
|
+
for doc in docs:
|
|
44
|
+
for line in {ln.strip() for ln in doc.page_content.splitlines() if ln.strip()}:
|
|
45
|
+
if len(line) < 80:
|
|
46
|
+
counter[line] += 1
|
|
47
|
+
|
|
48
|
+
cutoff = len(docs) * threshold
|
|
49
|
+
return {line for line, count in counter.items() if count >= cutoff}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _drop_lines(text: str, boilerplate: set) -> str:
|
|
53
|
+
"""Blank out whole lines that are boilerplate. Matching whole lines (not
|
|
54
|
+
substrings) keeps a short header like "Chapter" from being cut out of the
|
|
55
|
+
sentences that happen to contain it."""
|
|
56
|
+
if not boilerplate:
|
|
57
|
+
return text
|
|
58
|
+
return "\n".join("" if line.strip() in boilerplate else line for line in text.split("\n"))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def clean_documents(docs: List[Document], min_chars: int = 50) -> List[Document]:
|
|
62
|
+
"""Return cleaned copies; the input Documents are left untouched."""
|
|
63
|
+
boilerplate = _find_repeating_lines(docs)
|
|
64
|
+
|
|
65
|
+
cleaned: List[Document] = []
|
|
66
|
+
for doc in docs:
|
|
67
|
+
body = clean_text(_drop_lines(doc.page_content, boilerplate))
|
|
68
|
+
if len(body) < min_chars: # blank or near-blank page
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
cleaned.append(Document(id=doc.id, page_content=body, metadata=dict(doc.metadata)))
|
|
72
|
+
|
|
73
|
+
dropped = len(docs) - len(cleaned)
|
|
74
|
+
logger.info(f"[3] Cleaned : {len(cleaned)} pages kept, {dropped} dropped, "
|
|
75
|
+
f"{len(boilerplate)} repeating header/footer lines removed")
|
|
76
|
+
return cleaned
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""STEP 4 - Chunking.
|
|
2
|
+
|
|
3
|
+
Split each page into overlapping chunks. The overlap keeps a sentence that
|
|
4
|
+
straddles a boundary retrievable from either side.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
from langchain_core.documents import Document
|
|
11
|
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# Bengali danda (।) is included so Bangla PDFs split on sentence ends too.
|
|
16
|
+
SEPARATORS = ["\n\n", "\n", "। ", ". ", "? ", "! ", "; ", ", ", " ", ""]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def chunk_documents(docs: List[Document], chunk_size: int, chunk_overlap: int) -> List[Document]:
|
|
20
|
+
splitter = RecursiveCharacterTextSplitter(
|
|
21
|
+
chunk_size=chunk_size,
|
|
22
|
+
chunk_overlap=chunk_overlap,
|
|
23
|
+
separators=SEPARATORS,
|
|
24
|
+
length_function=len,
|
|
25
|
+
add_start_index=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
chunks = splitter.split_documents(docs)
|
|
29
|
+
|
|
30
|
+
for i, chunk in enumerate(chunks):
|
|
31
|
+
chunk.metadata["chunk_id"] = i
|
|
32
|
+
|
|
33
|
+
avg = sum(len(c.page_content) for c in chunks) / max(len(chunks), 1)
|
|
34
|
+
logger.info(f"[4] Chunked : {len(chunks)} chunks "
|
|
35
|
+
f"(size={chunk_size}, overlap={chunk_overlap}, avg={avg:.0f} chars)")
|
|
36
|
+
return chunks
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""STEP 5 - Embedding.
|
|
2
|
+
|
|
3
|
+
One embedding object is shared by ingestion (step 6) and querying (step 8) -
|
|
4
|
+
the same model must be used on both sides or the vectors are not comparable.
|
|
5
|
+
|
|
6
|
+
Two providers, chosen by EMBEDDING_PROVIDER in .env:
|
|
7
|
+
huggingface : a local sentence-transformers model, no API key, no quota
|
|
8
|
+
google : Gemini embeddings (needs an API key with embedding quota)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from rag_kit.errors import EmbeddingProviderError
|
|
15
|
+
from rag_kit.settings import Settings, default_settings
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
LOCAL_EXTRA_HINT = ('EMBEDDING_PROVIDER=huggingface needs the local extra: '
|
|
20
|
+
'pip install "ragkit-dev[local]"')
|
|
21
|
+
GEMINI_EXTRA_HINT = ('EMBEDDING_PROVIDER=google needs the gemini extra: '
|
|
22
|
+
'pip install "ragkit-dev[gemini]"')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_embeddings(settings: Optional[Settings] = None):
|
|
26
|
+
settings = settings or default_settings()
|
|
27
|
+
settings.validate()
|
|
28
|
+
if settings.embedding_provider == "huggingface":
|
|
29
|
+
try:
|
|
30
|
+
from langchain_huggingface import HuggingFaceEmbeddings
|
|
31
|
+
except ImportError as exc:
|
|
32
|
+
raise EmbeddingProviderError(f"{LOCAL_EXTRA_HINT} ({exc}).") from exc
|
|
33
|
+
try:
|
|
34
|
+
embeddings = HuggingFaceEmbeddings(model_name=settings.embedding_model)
|
|
35
|
+
except ImportError as exc: # sentence-transformers is imported only here
|
|
36
|
+
raise EmbeddingProviderError(f"{LOCAL_EXTRA_HINT} ({exc}).") from exc
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
raise EmbeddingProviderError(
|
|
39
|
+
f"Could not load embedding model {settings.embedding_model!r}: {exc}. "
|
|
40
|
+
"Check the model name and your network (the first run downloads it "
|
|
41
|
+
"into the Hugging Face cache)."
|
|
42
|
+
) from exc
|
|
43
|
+
logger.info(f"[5] Embedding : {settings.embedding_model} (local)")
|
|
44
|
+
return embeddings
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
from langchain_google_genai import GoogleGenerativeAIEmbeddings
|
|
48
|
+
except ImportError as exc:
|
|
49
|
+
raise EmbeddingProviderError(f"{GEMINI_EXTRA_HINT} ({exc}).") from exc
|
|
50
|
+
|
|
51
|
+
embeddings = GoogleGenerativeAIEmbeddings(
|
|
52
|
+
model=settings.embedding_model,
|
|
53
|
+
google_api_key=settings.google_api_key,
|
|
54
|
+
)
|
|
55
|
+
logger.info(f"[5] Embedding : {settings.embedding_model}")
|
|
56
|
+
return embeddings
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def detect_dimension(embeddings, settings: Optional[Settings] = None) -> int:
|
|
60
|
+
"""Ask the model for one vector and measure it.
|
|
61
|
+
|
|
62
|
+
Safer than hard-coding a size: it changes with the model (384 for
|
|
63
|
+
all-MiniLM-L6-v2, 3072 for gemini-embedding-001), and a mismatch only
|
|
64
|
+
shows up later as a Qdrant dimension error.
|
|
65
|
+
"""
|
|
66
|
+
settings = settings or default_settings()
|
|
67
|
+
if settings.embedding_dim:
|
|
68
|
+
return settings.embedding_dim
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
dim = len(embeddings.embed_query("dimension probe"))
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
raise EmbeddingProviderError(
|
|
74
|
+
f"Embedding model {settings.embedding_model!r} failed on a test input: {exc}"
|
|
75
|
+
) from exc
|
|
76
|
+
logger.info(f" vector size: {dim}")
|
|
77
|
+
return dim
|