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 ADDED
@@ -0,0 +1,44 @@
1
+ """rag-kit: a reusable RAG toolkit, one module per step of the pipeline.
2
+
3
+ from rag_kit import Pipeline, Settings
4
+
5
+ pipeline = Pipeline(settings=Settings.load("path/to/project"))
6
+
7
+ 1 step01_load_pdf Input document (.pdf, .txt, .md)
8
+ 2 step02_extract_text Text extraction
9
+ 3 step03_clean_text Text cleaning
10
+ 4 step04_chunking Chunking
11
+ 5 step05_embedding Embedding model
12
+ 6 step06_vector_store Store in Qdrant
13
+ 7 (the CLI / API) User question
14
+ 8 step08_query_embedding Query embedding
15
+ 9 step09_retrieve Retrieve relevant chunks
16
+ 10 step10_generate Gemini
17
+ 11 step11_answer Answer + sources
18
+
19
+ Steps report progress through logging (see rag_kit/log.py), never print().
20
+ """
21
+
22
+ import logging
23
+ from importlib.metadata import PackageNotFoundError, version
24
+
25
+ from rag_kit.errors import RagError
26
+ from rag_kit.settings import Settings
27
+
28
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
29
+
30
+ try:
31
+ __version__ = version("ragkit-dev")
32
+ except PackageNotFoundError: # running from a source tree that is not installed
33
+ __version__ = "0+unknown"
34
+
35
+ __all__ = ["AnswerResult", "Pipeline", "RagError", "Settings", "__version__"]
36
+
37
+
38
+ def __getattr__(name):
39
+ # Pipeline pulls in LangChain and Qdrant; load it only when asked for, so
40
+ # `import rag_kit` and `rag-kit --help` stay fast.
41
+ if name in ("Pipeline", "AnswerResult"):
42
+ from rag_kit import pipeline
43
+ return getattr(pipeline, name)
44
+ raise AttributeError(f"module 'rag_kit' has no attribute {name!r}")
rag_kit/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """python -m rag_kit: the same as the rag-kit command."""
2
+
3
+ from rag_kit.cli import main
4
+
5
+ raise SystemExit(main())
rag_kit/api.py ADDED
@@ -0,0 +1,155 @@
1
+ """Optional HTTP API over the pipeline (steps 7-11).
2
+
3
+ pip install "ragkit-dev[api]"
4
+ rag-kit serve # or: uvicorn rag_kit.api:app --port 8000
5
+
6
+ GET /health -> {"status": "ok", "ready": bool}
7
+ POST /query {question, top_k?} -> {"answer": str, "status": str,
8
+ "sources": [{page, source, score, marker, ...}]}
9
+
10
+ `answer` has the [S1] markers stripped; each source carries its `marker` so a
11
+ client can still map claims to chunks. `status` is "answered" or "no_results".
12
+ Questions longer than QUESTION_MAX_CHARS are rejected with 422.
13
+
14
+ `create_app(settings=..., pipeline=...)` builds an independent service, e.g.
15
+ for tests or for embedding it in another program; `app` is the default one,
16
+ configured from the project's .env (found from RAG_PROJECT_DIR or the current
17
+ folder when the first request or the startup warm-up needs it).
18
+
19
+ Qdrant in local mode allows only one process at a time - run ingestion first,
20
+ then start this with a single worker. The service binds to 127.0.0.1 unless
21
+ told otherwise, and has no authentication: do not expose it publicly.
22
+ """
23
+
24
+ import logging
25
+ import os
26
+ import threading
27
+ from contextlib import asynccontextmanager
28
+ from typing import List, Optional
29
+
30
+ from fastapi import Depends, FastAPI, HTTPException, Request
31
+ from pydantic import BaseModel, Field
32
+
33
+ from rag_kit.env import export_env_file
34
+ from rag_kit.errors import RagError
35
+ from rag_kit.log import show_progress
36
+ from rag_kit.pipeline import Pipeline
37
+ from rag_kit.settings import Settings, default_settings
38
+ from rag_kit.step11_answer import strip_markers # noqa: F401 - re-exported for callers
39
+
40
+ logger = logging.getLogger("rag_kit.api") # under "rag_kit" so show_progress() prints it
41
+
42
+ # Bounds the cost of a single request (embedding + LLM prompt size).
43
+ QUESTION_MAX_CHARS = 4000
44
+
45
+
46
+ def _pipeline_for(app: FastAPI) -> Pipeline:
47
+ """The app's one Pipeline, created on first use. The lock matters: requests run
48
+ in a thread pool, and two Pipelines would open embedded Qdrant twice."""
49
+ state = app.state
50
+ with state.pipeline_lock:
51
+ if state.pipeline is None:
52
+ state.pipeline = Pipeline(settings=state.settings)
53
+ state.owns_pipeline = True
54
+ return state.pipeline
55
+
56
+
57
+ def get_pipeline(request: Request) -> Pipeline:
58
+ """FastAPI dependency; tests swap it via app.dependency_overrides."""
59
+ try:
60
+ return _pipeline_for(request.app)
61
+ except RagError as exc: # e.g. no rag-kit project found
62
+ raise HTTPException(status_code=503, detail=f"RAG service not ready: {exc}") from exc
63
+
64
+
65
+ @asynccontextmanager
66
+ async def lifespan(app: FastAPI):
67
+ # Warm up at startup so the first real request is fast. If it fails (e.g. the
68
+ # collection has not been ingested yet) the service still starts and /health
69
+ # stays green; /query then returns a clear 503.
70
+ show_progress()
71
+ if app.state.export_env:
72
+ # Before the pipeline loads the embedding model (see rag_kit/env.py).
73
+ try:
74
+ exported = export_env_file(app.state.settings or default_settings())
75
+ if exported:
76
+ logger.info(f"[startup] exported from .env: {', '.join(exported)}")
77
+ except RagError as exc:
78
+ logger.warning(f"[startup] .env not exported: {exc}")
79
+ if os.getenv("RAG_API_WARMUP", "1") == "1":
80
+ try:
81
+ _pipeline_for(app).warm_up()
82
+ except Exception as exc: # noqa: BLE001 - reported back on /query
83
+ logger.warning(f"[startup] not ready yet: {exc}")
84
+ yield
85
+ with app.state.pipeline_lock:
86
+ if app.state.pipeline is not None and app.state.owns_pipeline:
87
+ app.state.pipeline.close()
88
+ app.state.pipeline = None
89
+
90
+
91
+ class QueryIn(BaseModel):
92
+ question: str = Field(max_length=QUESTION_MAX_CHARS)
93
+ top_k: Optional[int] = Field(default=None, ge=1, le=50)
94
+
95
+
96
+ class SourceOut(BaseModel):
97
+ page: Optional[int] = None
98
+ source: str
99
+ score: float
100
+ marker: str
101
+ chunk_id: Optional[int] = None
102
+ text: str
103
+
104
+
105
+ class AnswerOut(BaseModel):
106
+ answer: str
107
+ status: str
108
+ sources: List[SourceOut]
109
+
110
+
111
+ def health(request: Request) -> dict:
112
+ pipeline = request.app.state.pipeline
113
+ return {"status": "ok", "ready": pipeline is not None and pipeline.ready}
114
+
115
+
116
+ def query(body: QueryIn, pipeline: Pipeline = Depends(get_pipeline)) -> AnswerOut:
117
+ if not body.question.strip():
118
+ raise HTTPException(status_code=400, detail="question is required")
119
+
120
+ try:
121
+ result = pipeline.ask(body.question, k=body.top_k)
122
+ except RagError as exc:
123
+ raise HTTPException(status_code=503, detail=f"RAG service not ready: {exc}") from exc
124
+
125
+ return AnswerOut(
126
+ answer=result.answer_without_markers,
127
+ status=result.status,
128
+ sources=[
129
+ SourceOut(page=s["page"], source=s["file"] or "document", score=s["score"],
130
+ marker=s["marker"], chunk_id=s["chunk_id"], text=s["text"])
131
+ for s in result.sources
132
+ ],
133
+ )
134
+
135
+
136
+ def create_app(settings: Optional[Settings] = None, pipeline: Optional[Pipeline] = None,
137
+ export_env: bool = False) -> FastAPI:
138
+ """A service with its own Pipeline. Without `pipeline`, one is created from
139
+ `settings` (default: the project's .env) on first use and closed on shutdown;
140
+ a pipeline passed in is left for the caller to close.
141
+
142
+ export_env=True copies allow-listed .env keys (HF_HOME, proxies, ...) into
143
+ os.environ at startup; only the default `app` does that (see rag_kit/env.py)."""
144
+ app = FastAPI(title="RAG toolkit service", lifespan=lifespan)
145
+ app.state.settings = settings
146
+ app.state.export_env = export_env
147
+ app.state.pipeline = pipeline
148
+ app.state.owns_pipeline = False
149
+ app.state.pipeline_lock = threading.Lock()
150
+ app.get("/health")(health)
151
+ app.post("/query", response_model=AnswerOut)(query)
152
+ return app
153
+
154
+
155
+ app = create_app(export_env=True)
rag_kit/cli.py ADDED
@@ -0,0 +1,484 @@
1
+ """Command-line interface for the RAG toolkit (`rag-kit`, or `python -m rag_kit`).
2
+
3
+ rag-kit check [--deep] [--llm] validate config, dependencies, store
4
+ rag-kit ingest FILE [FILE ...] steps 1-6 (re-ingesting replaces)
5
+ rag-kit ask "question" steps 7-11 (no question = interactive)
6
+ rag-kit search "question" retrieval only, no LLM call
7
+ rag-kit documents [--json] files in the collection + chunk counts
8
+ rag-kit delete FILE_NAME remove one document's chunks
9
+ rag-kit serve FastAPI service (needs ragkit-dev[api])
10
+ rag-kit ui Streamlit test UI (needs ragkit-dev[ui])
11
+
12
+ The project (its .env, documents and Qdrant data) is --project DIR if given,
13
+ else RAG_PROJECT_DIR, else the nearest folder at or above the current
14
+ directory that contains rag-kit.toml. Every command takes --project and
15
+ --collection (to work on something other than COLLECTION_NAME).
16
+ """
17
+
18
+ import argparse
19
+ import contextlib
20
+ import importlib.metadata
21
+ import importlib.resources
22
+ import importlib.util
23
+ import json
24
+ import os
25
+ import re
26
+ import subprocess
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ from rag_kit.env import export_env_file
31
+ from rag_kit.errors import RagError
32
+ from rag_kit.log import show_progress
33
+ from rag_kit.settings import Settings, set_default_settings
34
+
35
+ DISTRIBUTION = "ragkit-dev"
36
+
37
+
38
+ def _pipeline(args):
39
+ from rag_kit.pipeline import Pipeline
40
+ return Pipeline(collection=args.collection, settings=args.settings)
41
+
42
+
43
+ def _quiet(args):
44
+ """With --json, step progress goes to stderr so stdout is only the JSON."""
45
+ if getattr(args, "json", False):
46
+ return contextlib.redirect_stdout(sys.stderr)
47
+ return contextlib.nullcontext()
48
+
49
+
50
+ # --- check -------------------------------------------------------------------
51
+ # The package metadata (pyproject.toml) is the single source of truth for
52
+ # dependencies; each optional group is an extra.
53
+ EXTRA_PURPOSE = {
54
+ "local": "local embeddings",
55
+ "gemini": "Gemini",
56
+ "api": "serve",
57
+ "ui": "ui",
58
+ "dev": "tests",
59
+ }
60
+
61
+
62
+ def _canonical(name: str) -> str:
63
+ return re.sub(r"[-_.]+", "-", name).lower()
64
+
65
+
66
+ def requirement_groups(requirements) -> list:
67
+ """(distribution name, extra or None for core) for each Requires-Dist line
68
+ that applies here, in order. References to this distribution (`ragkit-dev`) itself are
69
+ skipped."""
70
+ from packaging.requirements import Requirement
71
+
72
+ groups = []
73
+ for line in requirements:
74
+ requirement = Requirement(line)
75
+ if _canonical(requirement.name) == DISTRIBUTION:
76
+ continue
77
+ # Core first: a marker without `extra` (e.g. python_version) is true for
78
+ # every extra too, so it must not be filed under the first one.
79
+ if requirement.marker is None or requirement.marker.evaluate({"extra": ""}):
80
+ groups.append((requirement.name, None))
81
+ continue
82
+ extras = [extra for extra in EXTRA_PURPOSE
83
+ if requirement.marker.evaluate({"extra": extra})]
84
+ if extras:
85
+ groups.append((requirement.name, extras[0]))
86
+ return groups
87
+
88
+
89
+ def dependency_report(requirements=None):
90
+ """(all required installed?, [(status, name, detail), ...])."""
91
+ if requirements is None:
92
+ try:
93
+ requirements = importlib.metadata.requires(DISTRIBUTION) or []
94
+ except importlib.metadata.PackageNotFoundError:
95
+ return False, [("FAIL", DISTRIBUTION, "is not installed as a package: "
96
+ "pip install ragkit-dev (or pip install -e .)")]
97
+ ok, rows, seen = True, [], set()
98
+ for name, extra in requirement_groups(requirements):
99
+ if _canonical(name) in seen:
100
+ continue
101
+ seen.add(_canonical(name))
102
+ required = extra is None
103
+ purpose = "core" if required else f"optional: {EXTRA_PURPOSE[extra]}"
104
+ try:
105
+ version = importlib.metadata.version(name)
106
+ except importlib.metadata.PackageNotFoundError:
107
+ ok = ok and not required
108
+ hint = "pip install ragkit-dev" if required else f'pip install "ragkit-dev[{extra}]"'
109
+ rows.append(("FAIL" if required else "--", name, f"not installed ({purpose}): {hint}"))
110
+ else:
111
+ rows.append(("ok", name, f"{version} ({purpose})"))
112
+ return ok, rows
113
+
114
+
115
+ def _missing_env_hint(settings) -> str:
116
+ """How to create the missing settings file. Without it QDRANT_PATH is unset,
117
+ so rag-kit would look for a Qdrant server instead of a local store."""
118
+ defaults = f"until then defaults apply (Qdrant server at {settings.qdrant_url}, no API key)"
119
+ if settings.env_file != settings.project_dir / ".env":
120
+ return f"RAG_ENV_FILE points to a missing file; {defaults}"
121
+ if (settings.project_dir / ".env.example").exists():
122
+ return ("not created yet. In the project folder run `cp .env.example .env` "
123
+ "(PowerShell: `Copy-Item .env.example .env`), then set GOOGLE_API_KEY; "
124
+ f"{defaults}")
125
+ return (f"not created yet. Create .env in {settings.project_dir} (variables are listed "
126
+ f"in the rag-kit README); {defaults}")
127
+
128
+
129
+ def cmd_check(args) -> int:
130
+ ok = True
131
+
132
+ def line(status: str, label: str, detail: str = "") -> None:
133
+ print(f" [{status:^4}] {label:<22} {detail}")
134
+
135
+ settings = args.settings
136
+ if settings is None:
137
+ ok = False
138
+ print("Project : not found")
139
+ line("FAIL", "project", str(args.settings_error))
140
+ else:
141
+ env_file = settings.env_file
142
+ print(f"Project : {settings.project_dir}")
143
+ print(f"Config file : {env_file} ({'found' if env_file.exists() else 'MISSING'})")
144
+ print(f"Python : {sys.version.split()[0]}")
145
+ if settings is not None and not settings.env_file.exists():
146
+ line("warn", ".env", _missing_env_hint(settings))
147
+
148
+ if settings is not None:
149
+ try:
150
+ settings.validate()
151
+ line("ok", "settings", f"chunk {settings.chunk_size}/{settings.chunk_overlap}, "
152
+ f"top_k {settings.top_k}")
153
+ except RagError as exc:
154
+ ok = False
155
+ line("FAIL", "settings", str(exc))
156
+
157
+ line("ok" if settings.google_api_key else "warn", "GOOGLE_API_KEY",
158
+ "set" if settings.google_api_key else "missing - `ask` and the API cannot answer")
159
+ line("info", "embedding", f"{settings.embedding_provider}: {settings.embedding_model}")
160
+ line("info", "llm", settings.llm_model)
161
+ line("info", "pdf font repair", "on" if settings.pdf_font_repair else "off")
162
+ exported = getattr(args, "exported_env", [])
163
+ line("info", "env export", f"from .env: {', '.join(exported)} ({len(exported)})"
164
+ if exported else "nothing exported from .env")
165
+
166
+ deps_ok, rows = dependency_report()
167
+ ok = ok and deps_ok
168
+ for row in rows:
169
+ line(*row)
170
+
171
+ if settings is None:
172
+ print("\nSome required checks failed.")
173
+ return 1
174
+
175
+ from rag_kit.step06_vector_store import (
176
+ collection_dimension,
177
+ collection_exists,
178
+ describe_location,
179
+ get_client,
180
+ )
181
+ collection = args.collection or settings.collection_name
182
+ client = None
183
+ try:
184
+ client = get_client(create=False, settings=settings)
185
+ if collection_exists(client, collection, settings):
186
+ count = client.count(collection, exact=True).count
187
+ dim = collection_dimension(client, collection)
188
+ line("ok", "vector store", f"{describe_location(settings)}")
189
+ line("ok", "collection", f"'{collection}': {count} vectors, dim {dim}")
190
+ if args.deep:
191
+ from rag_kit.step05_embedding import detect_dimension, get_embeddings
192
+ model_dim = detect_dimension(get_embeddings(settings), settings)
193
+ match = model_dim == dim
194
+ ok = ok and match
195
+ line("ok" if match else "FAIL", "embedding dimension",
196
+ f"model {model_dim} vs collection {dim}")
197
+ else:
198
+ line("warn", "collection", f"'{collection}' not found in "
199
+ f"{describe_location(settings)} - run `ingest` first")
200
+ except RagError as exc:
201
+ line("warn", "vector store", str(exc))
202
+ finally:
203
+ if client is not None:
204
+ client.close()
205
+
206
+ if args.llm:
207
+ try:
208
+ from rag_kit.step10_generate import get_llm
209
+ reply = get_llm(settings).invoke("Reply with the single word: ok").content
210
+ line("ok", "llm call", f"{settings.llm_model} replied {str(reply).strip()[:30]!r}")
211
+ except Exception as exc:
212
+ ok = False
213
+ line("FAIL", "llm call", str(exc)[:300])
214
+
215
+ print("\nAll required checks passed." if ok else "\nSome required checks failed.")
216
+ return 0 if ok else 1
217
+
218
+
219
+ # --- ingest ------------------------------------------------------------------
220
+ def cmd_ingest(args) -> int:
221
+ files = args.files or [str(args.settings.pdf_path)]
222
+ if args.recreate and len(files) > 1:
223
+ print("--recreate with several files would keep only the last one; "
224
+ "recreate once, then ingest the rest.", file=sys.stderr)
225
+ return 2
226
+ font_repair = {"on": True, "off": False}.get(args.font_repair)
227
+
228
+ pipeline = _pipeline(args)
229
+ failed = 0
230
+ try:
231
+ for file in files:
232
+ print(f"--- INGEST {file} ".ljust(72, "-"), flush=True)
233
+ try:
234
+ report = pipeline.ingest(file, recreate=args.recreate, font_repair=font_repair)
235
+ print(f" -> {report['stored']} chunks from {report['pages']} "
236
+ f"page(s) in '{report['collection']}'")
237
+ except RagError as exc:
238
+ failed += 1
239
+ print(f"error: {exc}", file=sys.stderr)
240
+ finally:
241
+ pipeline.close()
242
+
243
+ if not failed:
244
+ print('\nDone. Now ask something: rag-kit ask "your question"')
245
+ return 1 if failed else 0
246
+
247
+
248
+ # --- ask / search ------------------------------------------------------------
249
+ def _print_chunks(scored_docs, full: bool) -> None:
250
+ for i, (doc, score) in enumerate(scored_docs, start=1):
251
+ meta = doc.metadata
252
+ print(f"\n[S{i}] score {score:.4f} | {meta.get('source_file')} | "
253
+ f"page {meta.get('page_number') or '-'} | chunk {meta.get('chunk_id')} | "
254
+ f"doc {meta.get('doc_id') or '-'}")
255
+ text = doc.page_content if full else doc.page_content[:300] + "..."
256
+ print(" " + text.replace("\n", "\n "))
257
+
258
+
259
+ def cmd_ask(args) -> int:
260
+ from rag_kit.step11_answer import present
261
+
262
+ pipeline = _pipeline(args)
263
+ out = sys.stdout
264
+
265
+ def answer(question: str) -> None:
266
+ with _quiet(args):
267
+ result = pipeline.ask(question, k=args.top_k, min_score=args.min_score)
268
+ if args.json:
269
+ print(json.dumps({"question": result.question, "answer": result.answer,
270
+ "status": result.status, "sources": result.sources},
271
+ ensure_ascii=False, indent=2), file=out)
272
+ return
273
+ present(result.question, result.answer, result.scored_docs)
274
+ if args.show_chunks:
275
+ _print_chunks(result.scored_docs, full=True)
276
+
277
+ try:
278
+ if args.question:
279
+ answer(" ".join(args.question))
280
+ return 0
281
+ print("Interactive mode. Type 'exit' to quit.")
282
+ while True:
283
+ try:
284
+ question = input("\nYour question: ").strip()
285
+ except (KeyboardInterrupt, EOFError):
286
+ print()
287
+ return 0
288
+ if question.lower() in {"exit", "quit", "q"}:
289
+ return 0
290
+ if question:
291
+ try:
292
+ answer(question)
293
+ except RagError as exc:
294
+ print(f"error: {exc}", file=sys.stderr)
295
+ finally:
296
+ pipeline.close()
297
+
298
+
299
+ def cmd_search(args) -> int:
300
+ pipeline = _pipeline(args)
301
+ try:
302
+ with _quiet(args):
303
+ scored_docs = pipeline.search(" ".join(args.question), k=args.top_k,
304
+ min_score=args.min_score)
305
+ finally:
306
+ pipeline.close()
307
+ if args.json:
308
+ from rag_kit.step11_answer import build_sources
309
+ print(json.dumps(build_sources(scored_docs), ensure_ascii=False, indent=2))
310
+ elif not scored_docs:
311
+ print("No chunks retrieved.")
312
+ else:
313
+ _print_chunks(scored_docs, full=args.full)
314
+ return 0
315
+
316
+
317
+ # --- documents / delete ------------------------------------------------------
318
+ def cmd_documents(args) -> int:
319
+ pipeline = _pipeline(args)
320
+ try:
321
+ with _quiet(args):
322
+ docs = pipeline.documents()
323
+ finally:
324
+ pipeline.close()
325
+ if args.json:
326
+ # Stable schema, documented in the README.
327
+ print(json.dumps({
328
+ "collection": pipeline.collection,
329
+ "total_chunks": sum(docs.values()),
330
+ "documents": [{"file": name, "chunks": count} for name, count in docs.items()],
331
+ }, ensure_ascii=False, indent=2))
332
+ return 0
333
+ print(f"Collection '{pipeline.collection}': {sum(docs.values())} chunks, {len(docs)} file(s)")
334
+ for name, count in docs.items():
335
+ print(f" {count:6} {name}")
336
+ return 0
337
+
338
+
339
+ def cmd_delete(args) -> int:
340
+ name = Path(args.file_name).name
341
+ if not args.yes:
342
+ reply = input(f"Delete every chunk of '{name}' from "
343
+ f"'{args.collection or args.settings.collection_name}'? [y/N] ")
344
+ if reply.strip().lower() not in {"y", "yes"}:
345
+ print("Cancelled.")
346
+ return 1
347
+ pipeline = _pipeline(args)
348
+ try:
349
+ removed = pipeline.delete(name)
350
+ finally:
351
+ pipeline.close()
352
+ print(f"Removed {removed} chunks of '{name}'." if removed
353
+ else f"No chunks found for '{name}'. `rag-kit documents` lists file names.")
354
+ return 0
355
+
356
+
357
+ # --- serve / ui --------------------------------------------------------------
358
+ def _require(module: str, extra: str) -> bool:
359
+ if importlib.util.find_spec(module) is None:
360
+ print(f'error: {module} is not installed. Run: pip install "ragkit-dev[{extra}]"',
361
+ file=sys.stderr)
362
+ return False
363
+ return True
364
+
365
+
366
+ def _child_env(args, what: str) -> dict:
367
+ """Environment for the server process: this project (RAG_PROJECT_DIR) and,
368
+ with --collection, COLLECTION_NAME for that process only (environment
369
+ variables win over .env). .env is never edited."""
370
+ collection = args.collection or args.settings.collection_name
371
+ source = "--collection" if args.collection else "COLLECTION_NAME"
372
+ print(f"{what} collection '{collection}' (from {source})", flush=True)
373
+ env = {**os.environ, "RAG_PROJECT_DIR": str(args.settings.project_dir)}
374
+ if args.collection:
375
+ env["COLLECTION_NAME"] = args.collection
376
+ return env
377
+
378
+
379
+ def cmd_serve(args) -> int:
380
+ if not (_require("fastapi", "api") and _require("uvicorn", "api")):
381
+ return 1
382
+ env = _child_env(args, "Serving")
383
+ # One worker: embedded Qdrant allows a single process.
384
+ return subprocess.call([sys.executable, "-m", "uvicorn", "rag_kit.api:app",
385
+ "--host", args.host, "--port", str(args.port), "--workers", "1"],
386
+ env=env)
387
+
388
+
389
+ def cmd_ui(args) -> int:
390
+ if not _require("streamlit", "ui"):
391
+ return 1
392
+ env = _child_env(args, "UI starts on")
393
+ app = importlib.resources.files("rag_kit.ui").joinpath("app.py")
394
+ with importlib.resources.as_file(app) as path:
395
+ return subprocess.call([sys.executable, "-m", "streamlit", "run", str(path),
396
+ "--server.port", str(args.port)], env=env)
397
+
398
+
399
+ # --- argument parsing --------------------------------------------------------
400
+ def build_parser() -> argparse.ArgumentParser:
401
+ parser = argparse.ArgumentParser(prog="rag-kit", description="Standalone RAG toolkit.")
402
+ common = argparse.ArgumentParser(add_help=False)
403
+ common.add_argument("--project", metavar="DIR",
404
+ help="project folder (default: RAG_PROJECT_DIR, else the nearest "
405
+ "folder with rag-kit.toml)")
406
+ common.add_argument("--collection", help="Qdrant collection (default: COLLECTION_NAME)")
407
+ sub = parser.add_subparsers(dest="command", required=True)
408
+
409
+ p = sub.add_parser("check", parents=[common], help="validate config, dependencies and store")
410
+ p.add_argument("--deep", action="store_true",
411
+ help="also load the embedding model and compare its dimension")
412
+ p.add_argument("--llm", action="store_true", help="also make one tiny LLM call (uses quota)")
413
+ p.set_defaults(func=cmd_check)
414
+
415
+ p = sub.add_parser("ingest", parents=[common], help="index documents (.pdf, .txt, .md)")
416
+ p.add_argument("files", nargs="*", help="files to ingest (default: PDF_PATH from .env)")
417
+ p.add_argument("--recreate", action="store_true",
418
+ help="drop the whole collection first (after changing the embedding model)")
419
+ p.add_argument("--font-repair", choices=["on", "off"],
420
+ help="override PDF_FONT_REPAIR for this run")
421
+ p.set_defaults(func=cmd_ingest)
422
+
423
+ for name, func, text in (("ask", cmd_ask, "answer a question with sources"),
424
+ ("search", cmd_search, "retrieval only - inspect chunks, no LLM")):
425
+ p = sub.add_parser(name, parents=[common], help=text)
426
+ p.add_argument("question", nargs="*" if name == "ask" else "+")
427
+ p.add_argument("-k", "--top-k", type=int, help="chunks to retrieve (default: TOP_K)")
428
+ p.add_argument("--min-score", type=float, help="drop chunks scoring below this")
429
+ p.add_argument("--json", action="store_true", help="machine-readable output")
430
+ if name == "ask":
431
+ p.add_argument("--show-chunks", action="store_true",
432
+ help="print the full text of every retrieved chunk")
433
+ else:
434
+ p.add_argument("--full", action="store_true", help="print full chunk text")
435
+ p.set_defaults(func=func)
436
+
437
+ p = sub.add_parser("documents", parents=[common], help="list indexed files")
438
+ p.add_argument("--json", action="store_true", help="machine-readable output")
439
+ p.set_defaults(func=cmd_documents)
440
+
441
+ p = sub.add_parser("delete", parents=[common], help="remove one file's chunks")
442
+ p.add_argument("file_name", help="file name as shown by `documents`")
443
+ p.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")
444
+ p.set_defaults(func=cmd_delete)
445
+
446
+ p = sub.add_parser("serve", parents=[common], help="run the FastAPI service")
447
+ p.add_argument("--host", default="127.0.0.1")
448
+ p.add_argument("--port", type=int, default=8000)
449
+ p.set_defaults(func=cmd_serve)
450
+
451
+ p = sub.add_parser("ui", parents=[common], help="run the Streamlit test UI")
452
+ p.add_argument("--port", type=int, default=8501)
453
+ p.set_defaults(func=cmd_ui)
454
+ return parser
455
+
456
+
457
+ def main(argv=None) -> int:
458
+ args = build_parser().parse_args(argv)
459
+ show_progress() # step progress to stdout (stderr under --json, see _quiet)
460
+ try:
461
+ args.settings = Settings.load(project_dir=args.project)
462
+ except RagError as exc:
463
+ if args.command != "check":
464
+ print(f"error: {exc}", file=sys.stderr)
465
+ return 1
466
+ args.settings, args.settings_error = None, exc # `check` reports it
467
+ else:
468
+ set_default_settings(args.settings)
469
+ # Before any command: HF_HOME, proxies etc. from .env must be in the
470
+ # environment before Hugging Face is first imported. Child processes
471
+ # (serve/ui) inherit them. See rag_kit/env.py for the allow-list.
472
+ args.exported_env = export_env_file(args.settings)
473
+ try:
474
+ return args.func(args)
475
+ except RagError as exc:
476
+ print(f"error: {exc}", file=sys.stderr)
477
+ return 1
478
+ except ValueError as exc:
479
+ print(f"error: {exc}", file=sys.stderr)
480
+ return 2
481
+
482
+
483
+ if __name__ == "__main__":
484
+ sys.exit(main())