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/__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
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())
|