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/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