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
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""STEP 6 - Store in Qdrant.
|
|
2
|
+
|
|
3
|
+
Two ways to run Qdrant:
|
|
4
|
+
server : docker run -p 6333:6333 -v qdrant_storage:/qdrant/storage qdrant/qdrant
|
|
5
|
+
local : set QDRANT_PATH=qdrant_data in .env (no server needed; one process
|
|
6
|
+
at a time)
|
|
7
|
+
|
|
8
|
+
Write side (`store_chunks`) creates the collection if needed. Read side
|
|
9
|
+
(`open_vector_store`) never creates anything: a missing store or collection, or
|
|
10
|
+
one built with a different embedding size, is an error with a fix in the message.
|
|
11
|
+
|
|
12
|
+
Re-indexing: every chunk gets a deterministic point ID built from its file name,
|
|
13
|
+
content hash and position. Ingesting a file again overwrites those points and
|
|
14
|
+
then deletes any older chunks of the same file name that were not overwritten,
|
|
15
|
+
so a document is *replaced*, never duplicated. `delete_document` removes one
|
|
16
|
+
document; `--recreate` drops the whole collection.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
from langchain_core.documents import Document
|
|
26
|
+
from langchain_qdrant import QdrantVectorStore
|
|
27
|
+
from qdrant_client import QdrantClient
|
|
28
|
+
from qdrant_client.http import models
|
|
29
|
+
|
|
30
|
+
from rag_kit.errors import EmptyDocumentError, RagError, VectorStoreError
|
|
31
|
+
from rag_kit.settings import Settings, default_settings
|
|
32
|
+
from rag_kit.step05_embedding import detect_dimension
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# Fixed namespace so the same chunk always maps to the same point ID.
|
|
37
|
+
_ID_NAMESPACE = uuid.UUID("8d2f1c3e-5b7a-4e0f-9c61-2a4b6d8e0f13")
|
|
38
|
+
_SOURCE_KEY = "metadata.source_file"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def describe_location(settings: Optional[Settings] = None) -> str:
|
|
42
|
+
settings = settings or default_settings()
|
|
43
|
+
if settings.qdrant_path:
|
|
44
|
+
return f"local Qdrant store {settings.qdrant_path}"
|
|
45
|
+
return f"Qdrant server {settings.qdrant_url}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_client(create: bool = True, settings: Optional[Settings] = None) -> QdrantClient:
|
|
49
|
+
"""Open Qdrant. With create=False a missing local store is an error instead
|
|
50
|
+
of being silently created empty."""
|
|
51
|
+
settings = settings or default_settings()
|
|
52
|
+
if settings.qdrant_path:
|
|
53
|
+
path = Path(settings.qdrant_path)
|
|
54
|
+
if not create and not path.exists():
|
|
55
|
+
raise VectorStoreError(
|
|
56
|
+
f"No local Qdrant store at {path}. Run `rag-kit ingest <file>` first, "
|
|
57
|
+
f"or point QDRANT_PATH at the existing store (relative paths are resolved "
|
|
58
|
+
f"from {settings.project_dir})."
|
|
59
|
+
)
|
|
60
|
+
try:
|
|
61
|
+
return QdrantClient(path=str(path))
|
|
62
|
+
except RuntimeError as exc:
|
|
63
|
+
if "already accessed" in str(exc):
|
|
64
|
+
raise VectorStoreError(
|
|
65
|
+
f"The local Qdrant store at {path} is in use by another process (the "
|
|
66
|
+
"API server, the Streamlit UI or another CLI command). Embedded Qdrant "
|
|
67
|
+
"allows one process at a time: stop the other one, or run a Qdrant "
|
|
68
|
+
"server and set QDRANT_URL instead of QDRANT_PATH."
|
|
69
|
+
) from exc
|
|
70
|
+
raise VectorStoreError(f"Could not open {describe_location(settings)}: {exc}") from exc
|
|
71
|
+
return QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def collection_exists(client: QdrantClient, name: str,
|
|
75
|
+
settings: Optional[Settings] = None) -> bool:
|
|
76
|
+
try:
|
|
77
|
+
return client.collection_exists(name)
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
raise VectorStoreError(
|
|
80
|
+
f"Cannot reach {describe_location(settings)}: {exc}. Start the server "
|
|
81
|
+
"(docker run -p 6333:6333 qdrant/qdrant) or set QDRANT_PATH for local mode."
|
|
82
|
+
) from exc
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def collection_dimension(client: QdrantClient, name: str) -> int:
|
|
86
|
+
vectors = client.get_collection(name).config.params.vectors
|
|
87
|
+
if isinstance(vectors, dict): # named vectors; LangChain uses the unnamed one
|
|
88
|
+
vectors = vectors.get("") or next(iter(vectors.values()))
|
|
89
|
+
return vectors.size
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def check_dimension(client: QdrantClient, name: str, expected: int,
|
|
93
|
+
settings: Optional[Settings] = None) -> None:
|
|
94
|
+
actual = collection_dimension(client, name)
|
|
95
|
+
if actual != expected:
|
|
96
|
+
raise VectorStoreError(
|
|
97
|
+
f"Collection '{name}' holds {actual}-dimensional vectors, but "
|
|
98
|
+
f"{(settings or default_settings()).embedding_model} produces {expected}. "
|
|
99
|
+
"Vectors from different models "
|
|
100
|
+
"are not comparable: set EMBEDDING_MODEL back to the model that built this "
|
|
101
|
+
"collection, use another COLLECTION_NAME, or rebuild it with --recreate."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def ensure_collection(client: QdrantClient, dimension: int, recreate: bool = False,
|
|
106
|
+
collection: Optional[str] = None,
|
|
107
|
+
settings: Optional[Settings] = None) -> None:
|
|
108
|
+
settings = settings or default_settings()
|
|
109
|
+
name = collection or settings.collection_name
|
|
110
|
+
exists = collection_exists(client, name, settings)
|
|
111
|
+
|
|
112
|
+
if exists and recreate:
|
|
113
|
+
client.delete_collection(name)
|
|
114
|
+
exists = False
|
|
115
|
+
logger.info(f" dropped old collection '{name}'")
|
|
116
|
+
|
|
117
|
+
if exists:
|
|
118
|
+
check_dimension(client, name, dimension, settings)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
client.create_collection(
|
|
122
|
+
collection_name=name,
|
|
123
|
+
vectors_config=models.VectorParams(size=dimension, distance=models.Distance.COSINE),
|
|
124
|
+
)
|
|
125
|
+
logger.info(f" created collection '{name}' (dim={dimension}, cosine)")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_vector_store(embeddings, client: QdrantClient = None,
|
|
129
|
+
collection: Optional[str] = None,
|
|
130
|
+
settings: Optional[Settings] = None) -> QdrantVectorStore:
|
|
131
|
+
settings = settings or default_settings()
|
|
132
|
+
return QdrantVectorStore(
|
|
133
|
+
client=client or get_client(settings=settings),
|
|
134
|
+
collection_name=collection or settings.collection_name,
|
|
135
|
+
embedding=embeddings,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def open_vector_store(embeddings, client: QdrantClient,
|
|
140
|
+
collection: Optional[str] = None,
|
|
141
|
+
settings: Optional[Settings] = None) -> QdrantVectorStore:
|
|
142
|
+
"""Read side: validate that the collection exists and matches the model."""
|
|
143
|
+
settings = settings or default_settings()
|
|
144
|
+
name = collection or settings.collection_name
|
|
145
|
+
if not collection_exists(client, name, settings):
|
|
146
|
+
found = [c.name for c in client.get_collections().collections]
|
|
147
|
+
raise VectorStoreError(
|
|
148
|
+
f"Collection '{name}' does not exist in {describe_location(settings)} "
|
|
149
|
+
f"(found: {', '.join(found) or 'none'}). Ingest a document first, or fix "
|
|
150
|
+
"COLLECTION_NAME."
|
|
151
|
+
)
|
|
152
|
+
check_dimension(client, name, detect_dimension(embeddings, settings), settings)
|
|
153
|
+
return get_vector_store(embeddings, client, name, settings)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def chunk_point_id(metadata: dict) -> str:
|
|
157
|
+
key = f"{metadata['source_file']}|{metadata.get('doc_id')}|{metadata['chunk_id']}"
|
|
158
|
+
return str(uuid.uuid5(_ID_NAMESPACE, key))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _source_filter(source_file: str, keep_ids: Optional[List[str]] = None) -> models.Filter:
|
|
162
|
+
return models.Filter(
|
|
163
|
+
must=[models.FieldCondition(key=_SOURCE_KEY, match=models.MatchValue(value=source_file))],
|
|
164
|
+
must_not=[models.HasIdCondition(has_id=keep_ids)] if keep_ids else None,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def store_chunks(chunks: List[Document], embeddings, recreate: bool = False,
|
|
169
|
+
client: QdrantClient = None, collection: Optional[str] = None,
|
|
170
|
+
settings: Optional[Settings] = None) -> dict:
|
|
171
|
+
if not chunks:
|
|
172
|
+
raise EmptyDocumentError("Nothing to store: the document produced no chunks.")
|
|
173
|
+
|
|
174
|
+
settings = settings or default_settings()
|
|
175
|
+
client = client or get_client(settings=settings)
|
|
176
|
+
name = collection or settings.collection_name
|
|
177
|
+
ensure_collection(client, detect_dimension(embeddings, settings), recreate=recreate,
|
|
178
|
+
collection=name, settings=settings)
|
|
179
|
+
store = get_vector_store(embeddings, client, name, settings)
|
|
180
|
+
ids = [chunk_point_id(c.metadata) for c in chunks]
|
|
181
|
+
|
|
182
|
+
# Gemini caps a single embed call at 100 texts, so send in batches.
|
|
183
|
+
# On the free tier it also caps ~100 texts/minute, so pause between batches.
|
|
184
|
+
batch = settings.embed_batch_size
|
|
185
|
+
for i, start in enumerate(range(0, len(chunks), batch)):
|
|
186
|
+
if i > 0 and settings.embed_sleep:
|
|
187
|
+
time.sleep(settings.embed_sleep)
|
|
188
|
+
try:
|
|
189
|
+
store.add_documents(chunks[start:start + batch], ids=ids[start:start + batch])
|
|
190
|
+
except RagError:
|
|
191
|
+
raise
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
raise VectorStoreError(
|
|
194
|
+
f"Storing chunks failed after {start}/{len(chunks)}: {exc}. Running ingest "
|
|
195
|
+
"again is safe - chunks are keyed by file and position, so nothing is "
|
|
196
|
+
"duplicated."
|
|
197
|
+
) from exc
|
|
198
|
+
logger.info(f" stored {min(start + batch, len(chunks))}/{len(chunks)} chunks")
|
|
199
|
+
|
|
200
|
+
# Replace, not append: drop older chunks of the same file that this run
|
|
201
|
+
# did not overwrite (e.g. the file shrank or its content changed).
|
|
202
|
+
replaced = 0
|
|
203
|
+
for source in sorted({c.metadata["source_file"] for c in chunks}):
|
|
204
|
+
source_ids = [pid for pid, c in zip(ids, chunks) if c.metadata["source_file"] == source]
|
|
205
|
+
stale = _source_filter(source, keep_ids=source_ids)
|
|
206
|
+
count = client.count(name, count_filter=stale, exact=True).count
|
|
207
|
+
if count:
|
|
208
|
+
client.delete(name, points_selector=models.FilterSelector(filter=stale))
|
|
209
|
+
replaced += count
|
|
210
|
+
|
|
211
|
+
total = client.count(name, exact=True).count
|
|
212
|
+
note = f", {replaced} outdated chunks removed" if replaced else ""
|
|
213
|
+
logger.info(f"[6] Qdrant : collection '{name}' now holds {total} vectors{note}")
|
|
214
|
+
return {"collection": name, "stored": len(chunks), "removed_outdated": replaced,
|
|
215
|
+
"total": total}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def delete_document(client: QdrantClient, source_file: str,
|
|
219
|
+
collection: Optional[str] = None,
|
|
220
|
+
settings: Optional[Settings] = None) -> int:
|
|
221
|
+
"""Remove every chunk of one file. Returns how many were deleted."""
|
|
222
|
+
settings = settings or default_settings()
|
|
223
|
+
name = collection or settings.collection_name
|
|
224
|
+
if not collection_exists(client, name, settings):
|
|
225
|
+
raise VectorStoreError(
|
|
226
|
+
f"Collection '{name}' does not exist in {describe_location(settings)}.")
|
|
227
|
+
selector = _source_filter(source_file)
|
|
228
|
+
count = client.count(name, count_filter=selector, exact=True).count
|
|
229
|
+
if count:
|
|
230
|
+
client.delete(name, points_selector=models.FilterSelector(filter=selector))
|
|
231
|
+
return count
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def list_documents(client: QdrantClient, collection: Optional[str] = None,
|
|
235
|
+
settings: Optional[Settings] = None) -> Dict[str, int]:
|
|
236
|
+
"""File name -> number of chunks stored for it."""
|
|
237
|
+
settings = settings or default_settings()
|
|
238
|
+
name = collection or settings.collection_name
|
|
239
|
+
if not collection_exists(client, name, settings):
|
|
240
|
+
raise VectorStoreError(
|
|
241
|
+
f"Collection '{name}' does not exist in {describe_location(settings)}.")
|
|
242
|
+
counts: Dict[str, int] = {}
|
|
243
|
+
offset = None
|
|
244
|
+
while True:
|
|
245
|
+
points, offset = client.scroll(name, limit=512, offset=offset,
|
|
246
|
+
with_payload=True, with_vectors=False)
|
|
247
|
+
for point in points:
|
|
248
|
+
source = (point.payload or {}).get("metadata", {}).get("source_file", "(unknown)")
|
|
249
|
+
counts[source] = counts.get(source, 0) + 1
|
|
250
|
+
if offset is None:
|
|
251
|
+
return dict(sorted(counts.items()))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""STEP 8 - Query embedding.
|
|
2
|
+
|
|
3
|
+
The question is turned into a vector with the same model used in step 5.
|
|
4
|
+
LangChain's retriever would do this internally, but doing it explicitly keeps
|
|
5
|
+
the pipeline visible and lets you inspect or cache the vector.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def embed_question(embeddings, question: str) -> List[float]:
|
|
15
|
+
vector = embeddings.embed_query(question)
|
|
16
|
+
logger.info(f"[8] Query vec : {len(vector)} dimensions")
|
|
17
|
+
return vector
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""STEP 9 - Retrieve relevant chunks.
|
|
2
|
+
|
|
3
|
+
Cosine similarity search in Qdrant against the query vector from step 8.
|
|
4
|
+
Returns (Document, score) pairs, highest score first. With a `min_score`
|
|
5
|
+
(MIN_SCORE in .env), weaker hits are dropped, and an empty result means
|
|
6
|
+
"nothing relevant" rather than "the k least-bad chunks".
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from langchain_core.documents import Document
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def retrieve(vector_store, query_vector: List[float], k: int,
|
|
18
|
+
min_score: Optional[float] = None) -> List[Tuple[Document, float]]:
|
|
19
|
+
results = vector_store.similarity_search_with_score_by_vector(query_vector, k=k)
|
|
20
|
+
|
|
21
|
+
dropped = 0
|
|
22
|
+
if min_score is not None:
|
|
23
|
+
kept = [(doc, score) for doc, score in results if score >= min_score]
|
|
24
|
+
dropped = len(results) - len(kept)
|
|
25
|
+
results = kept
|
|
26
|
+
|
|
27
|
+
note = f" ({dropped} below min score {min_score})" if dropped else ""
|
|
28
|
+
logger.info(f"[9] Retrieved : {len(results)} chunks{note}")
|
|
29
|
+
for i, (doc, score) in enumerate(results, start=1):
|
|
30
|
+
page = doc.metadata.get("page_number") or "-"
|
|
31
|
+
preview = doc.page_content[:70].replace("\n", " ")
|
|
32
|
+
logger.info(f" S{i} page {page} | score {score:.3f} | {preview}...")
|
|
33
|
+
|
|
34
|
+
return results
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""STEP 10 - Gemini.
|
|
2
|
+
|
|
3
|
+
The retrieved chunks become the context. The prompt forces the model to answer
|
|
4
|
+
from that context only, and to tag every claim with the [S1]..[Sn] marker of
|
|
5
|
+
the chunk it came from - that is what makes step 11's sources trustworthy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from langchain_core.documents import Document
|
|
12
|
+
from langchain_core.output_parsers import StrOutputParser
|
|
13
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
14
|
+
|
|
15
|
+
from rag_kit.errors import GenerationError
|
|
16
|
+
from rag_kit.settings import Settings, default_settings
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
NO_RESULTS_ANSWER = "Nothing relevant was retrieved from the documents for this question."
|
|
21
|
+
|
|
22
|
+
SYSTEM_PROMPT = """You answer questions about a set of documents.
|
|
23
|
+
|
|
24
|
+
Rules:
|
|
25
|
+
- Use ONLY the context below. Do not add outside knowledge.
|
|
26
|
+
- After each claim, put the marker of the chunk it came from, like [S1] or [S2].
|
|
27
|
+
- If the context does not contain the answer, say exactly that the document does
|
|
28
|
+
not cover it. Do not guess.
|
|
29
|
+
- Answer in the same language the question was asked in.
|
|
30
|
+
- Be direct. No preamble."""
|
|
31
|
+
|
|
32
|
+
PROMPT = ChatPromptTemplate.from_messages([
|
|
33
|
+
("system", SYSTEM_PROMPT),
|
|
34
|
+
("human", "Context:\n{context}\n\nQuestion: {question}"),
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_llm(settings: Optional[Settings] = None) -> "ChatGoogleGenerativeAI": # noqa: F821
|
|
39
|
+
settings = settings or default_settings()
|
|
40
|
+
settings.require_api_key()
|
|
41
|
+
# Imported here so ingestion and retrieval work without the Gemini SDK.
|
|
42
|
+
try:
|
|
43
|
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
|
44
|
+
except ImportError as exc:
|
|
45
|
+
raise GenerationError(
|
|
46
|
+
f'Answer generation needs the gemini extra: pip install "ragkit-dev[gemini]" ({exc}).'
|
|
47
|
+
) from exc
|
|
48
|
+
return ChatGoogleGenerativeAI(
|
|
49
|
+
model=settings.llm_model,
|
|
50
|
+
google_api_key=settings.google_api_key,
|
|
51
|
+
temperature=settings.temperature,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def format_context(scored_docs: List[Tuple[Document, float]]) -> str:
|
|
56
|
+
blocks = []
|
|
57
|
+
for i, (doc, _score) in enumerate(scored_docs, start=1):
|
|
58
|
+
page = doc.metadata.get("page_number")
|
|
59
|
+
source = doc.metadata.get("source_file", "document")
|
|
60
|
+
where = f"{source}, page {page}" if page else source
|
|
61
|
+
blocks.append(f"[S{i}] ({where})\n{doc.page_content}")
|
|
62
|
+
return "\n\n---\n\n".join(blocks)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def generate_answer(question: str, scored_docs: List[Tuple[Document, float]], llm=None,
|
|
66
|
+
settings: Optional[Settings] = None) -> str:
|
|
67
|
+
if not scored_docs:
|
|
68
|
+
return NO_RESULTS_ANSWER
|
|
69
|
+
|
|
70
|
+
settings = settings or default_settings()
|
|
71
|
+
chain = PROMPT | (llm or get_llm(settings)) | StrOutputParser()
|
|
72
|
+
try:
|
|
73
|
+
answer = chain.invoke({
|
|
74
|
+
"context": format_context(scored_docs),
|
|
75
|
+
"question": question,
|
|
76
|
+
})
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
raise GenerationError(
|
|
79
|
+
f"{settings.llm_model} could not answer: {exc}. Check GOOGLE_API_KEY, the "
|
|
80
|
+
"LLM_MODEL name and your quota (HTTP 429 means rate-limited; wait and retry)."
|
|
81
|
+
) from exc
|
|
82
|
+
|
|
83
|
+
logger.info(f"[10] Gemini : {settings.llm_model} answered")
|
|
84
|
+
return answer.strip()
|
rag_kit/step11_answer.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""STEP 11 - Answer + sources."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import List, Tuple
|
|
5
|
+
|
|
6
|
+
from langchain_core.documents import Document
|
|
7
|
+
|
|
8
|
+
# Gemini tags claims with [S1], [S2]... (sometimes back-to-back).
|
|
9
|
+
_MARKER_RE = re.compile(r"[ \t]*(?:\[S\d+\])+")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def strip_markers(text: str) -> str:
|
|
13
|
+
"""Remove [S1]-style citation markers, e.g. for a UI that lists sources separately."""
|
|
14
|
+
return _MARKER_RE.sub("", text).strip()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_sources(scored_docs: List[Tuple[Document, float]]) -> List[dict]:
|
|
18
|
+
"""One entry per retrieved chunk; `marker` matches the [S1].. tags in the answer."""
|
|
19
|
+
return [
|
|
20
|
+
{
|
|
21
|
+
"marker": f"S{i}",
|
|
22
|
+
"page": doc.metadata.get("page_number"),
|
|
23
|
+
"file": doc.metadata.get("source_file"),
|
|
24
|
+
"score": float(score),
|
|
25
|
+
"chunk_id": doc.metadata.get("chunk_id"),
|
|
26
|
+
"doc_id": doc.metadata.get("doc_id"),
|
|
27
|
+
"text": doc.page_content,
|
|
28
|
+
}
|
|
29
|
+
for i, (doc, score) in enumerate(scored_docs, start=1)
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_sources(scored_docs: List[Tuple[Document, float]], preview_chars: int = 160) -> str:
|
|
34
|
+
lines = []
|
|
35
|
+
for i, (doc, score) in enumerate(scored_docs, start=1):
|
|
36
|
+
page = doc.metadata.get("page_number")
|
|
37
|
+
source = doc.metadata.get("source_file", "document")
|
|
38
|
+
where = f"{source}, page {page}" if page else source
|
|
39
|
+
preview = " ".join(doc.page_content[:preview_chars].split())
|
|
40
|
+
lines.append(f" [S{i}] {where} (score {score:.3f})\n {preview}...")
|
|
41
|
+
return "\n".join(lines)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def present(question: str, answer: str, scored_docs: List[Tuple[Document, float]]) -> dict:
|
|
45
|
+
print("\n" + "=" * 72)
|
|
46
|
+
print(f"Q: {question}")
|
|
47
|
+
print("-" * 72)
|
|
48
|
+
print(answer)
|
|
49
|
+
print("-" * 72)
|
|
50
|
+
print("Sources:" if scored_docs else "Sources: none (nothing relevant was retrieved)")
|
|
51
|
+
if scored_docs:
|
|
52
|
+
print(format_sources(scored_docs))
|
|
53
|
+
print("=" * 72)
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
"question": question,
|
|
57
|
+
"answer": answer,
|
|
58
|
+
"status": "answered" if scored_docs else "no_results",
|
|
59
|
+
"sources": build_sources(scored_docs),
|
|
60
|
+
}
|
rag_kit/ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The optional Streamlit test UI (`rag-kit ui`); app.py is run by streamlit."""
|
rag_kit/ui/app.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Optional Streamlit UI for testing the pipeline - not a production app.
|
|
2
|
+
|
|
3
|
+
pip install "ragkit-dev[ui]"
|
|
4
|
+
rag-kit ui # http://localhost:8501
|
|
5
|
+
|
|
6
|
+
Upload and ingest documents, ask questions, and inspect the retrieved chunks.
|
|
7
|
+
Uploads are saved under the project's uploads/ folder (git-ignored). Local
|
|
8
|
+
Qdrant allows one process at a time, so stop `serve` or other CLI commands
|
|
9
|
+
while this runs. `rag-kit ui` passes the project in RAG_PROJECT_DIR.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import streamlit as st
|
|
15
|
+
|
|
16
|
+
from rag_kit.env import export_env_file
|
|
17
|
+
from rag_kit.errors import RagError
|
|
18
|
+
from rag_kit.log import show_progress
|
|
19
|
+
from rag_kit.pipeline import Pipeline
|
|
20
|
+
from rag_kit.settings import SUPPORTED_EXTENSIONS, default_settings
|
|
21
|
+
from rag_kit.step05_embedding import get_embeddings
|
|
22
|
+
from rag_kit.step06_vector_store import describe_location, get_client
|
|
23
|
+
|
|
24
|
+
st.set_page_config(page_title="RAG toolkit - test UI", layout="wide")
|
|
25
|
+
show_progress() # step progress in the terminal running streamlit, as before
|
|
26
|
+
try:
|
|
27
|
+
settings = default_settings()
|
|
28
|
+
except RagError as exc: # no rag-kit project found
|
|
29
|
+
st.error(str(exc))
|
|
30
|
+
st.stop()
|
|
31
|
+
export_env_file(settings) # HF_HOME, proxies etc. before the model loads
|
|
32
|
+
UPLOAD_DIR = settings.project_dir / "uploads"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@st.cache_resource(show_spinner="Loading embedding model...")
|
|
36
|
+
def shared_embeddings():
|
|
37
|
+
return get_embeddings(settings)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@st.cache_resource(show_spinner="Opening Qdrant...")
|
|
41
|
+
def shared_client():
|
|
42
|
+
return get_client(create=True, settings=settings)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pipeline_for(collection: str) -> Pipeline:
|
|
46
|
+
# One client and one model for the whole app; a Pipeline per collection.
|
|
47
|
+
cache = st.session_state.setdefault("pipelines", {})
|
|
48
|
+
if collection not in cache:
|
|
49
|
+
cache[collection] = Pipeline(collection=collection, embeddings=shared_embeddings(),
|
|
50
|
+
client=shared_client(), settings=settings)
|
|
51
|
+
return cache[collection]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def show_error(exc: Exception) -> None:
|
|
55
|
+
st.error(str(exc) if isinstance(exc, (RagError, ValueError)) else f"Unexpected error: {exc!r}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- sidebar: settings + indexed documents -------------------------------------
|
|
59
|
+
with st.sidebar:
|
|
60
|
+
st.header("Settings")
|
|
61
|
+
collection = st.text_input("Collection", value=settings.collection_name).strip()
|
|
62
|
+
top_k = st.slider("Chunks to retrieve (top k)", 1, 20, settings.top_k)
|
|
63
|
+
min_score = st.number_input("Min score (0 = keep all)", 0.0, 1.0,
|
|
64
|
+
float(settings.min_score or 0.0), 0.05)
|
|
65
|
+
st.caption(f"Store: {describe_location(settings)}")
|
|
66
|
+
st.caption(f"Embedding: {settings.embedding_model} \nLLM: {settings.llm_model}")
|
|
67
|
+
if not settings.google_api_key:
|
|
68
|
+
st.warning("GOOGLE_API_KEY is not set - retrieval works, answers will fail.")
|
|
69
|
+
|
|
70
|
+
st.header("Indexed documents")
|
|
71
|
+
try:
|
|
72
|
+
docs = pipeline_for(collection).documents()
|
|
73
|
+
if docs:
|
|
74
|
+
st.dataframe([{"file": k, "chunks": v} for k, v in docs.items()],
|
|
75
|
+
hide_index=True, use_container_width=True)
|
|
76
|
+
else:
|
|
77
|
+
st.caption("Collection is empty.")
|
|
78
|
+
except RagError as exc:
|
|
79
|
+
st.caption(str(exc))
|
|
80
|
+
|
|
81
|
+
if not collection:
|
|
82
|
+
st.stop()
|
|
83
|
+
pipeline = pipeline_for(collection)
|
|
84
|
+
|
|
85
|
+
st.title("RAG toolkit - test UI")
|
|
86
|
+
tab_ask, tab_ingest = st.tabs(["Ask", "Ingest"])
|
|
87
|
+
|
|
88
|
+
# --- ingest -----------------------------------------------------------------------
|
|
89
|
+
with tab_ingest:
|
|
90
|
+
uploads = st.file_uploader("Documents", type=[e.lstrip(".") for e in SUPPORTED_EXTENSIONS],
|
|
91
|
+
accept_multiple_files=True)
|
|
92
|
+
font_repair = st.checkbox("PDF font repair", value=settings.pdf_font_repair,
|
|
93
|
+
help="Only for PDFs whose text extracts as scrambled glyphs.")
|
|
94
|
+
st.caption(f"Re-ingesting a file with the same name replaces its chunks in '{collection}'.")
|
|
95
|
+
if st.button("Ingest", disabled=not uploads, type="primary"):
|
|
96
|
+
UPLOAD_DIR.mkdir(exist_ok=True)
|
|
97
|
+
for upload in uploads:
|
|
98
|
+
path = UPLOAD_DIR / Path(upload.name).name
|
|
99
|
+
path.write_bytes(upload.getbuffer())
|
|
100
|
+
with st.status(f"Ingesting {path.name}...") as status:
|
|
101
|
+
try:
|
|
102
|
+
report = pipeline.ingest(path, font_repair=font_repair)
|
|
103
|
+
status.update(label=f"{path.name}: {report['stored']} chunks from "
|
|
104
|
+
f"{report['pages']} page(s), collection now holds "
|
|
105
|
+
f"{report['total']}", state="complete")
|
|
106
|
+
if report["removed_outdated"]:
|
|
107
|
+
st.caption(f"Removed {report['removed_outdated']} outdated chunks.")
|
|
108
|
+
except Exception as exc: # noqa: BLE001 - shown in the UI
|
|
109
|
+
status.update(label=f"{path.name}: failed", state="error")
|
|
110
|
+
show_error(exc)
|
|
111
|
+
|
|
112
|
+
# --- ask ----------------------------------------------------------------------------
|
|
113
|
+
with tab_ask:
|
|
114
|
+
question = st.text_area("Question", placeholder="What does the document say about ...?")
|
|
115
|
+
col_ask, col_search = st.columns([1, 1])
|
|
116
|
+
run_ask = col_ask.button("Ask", type="primary", disabled=not question.strip())
|
|
117
|
+
run_search = col_search.button("Retrieve only (no LLM)", disabled=not question.strip())
|
|
118
|
+
|
|
119
|
+
if run_ask or run_search:
|
|
120
|
+
try:
|
|
121
|
+
with st.spinner("Working..."):
|
|
122
|
+
if run_ask:
|
|
123
|
+
result = pipeline.ask(question, k=top_k, min_score=min_score or None)
|
|
124
|
+
scored = result.scored_docs
|
|
125
|
+
else:
|
|
126
|
+
result = None
|
|
127
|
+
scored = pipeline.search(question, k=top_k, min_score=min_score or None)
|
|
128
|
+
except Exception as exc: # noqa: BLE001 - shown in the UI
|
|
129
|
+
show_error(exc)
|
|
130
|
+
st.stop()
|
|
131
|
+
|
|
132
|
+
if result is not None:
|
|
133
|
+
st.subheader("Answer")
|
|
134
|
+
if result.status == "no_results":
|
|
135
|
+
st.info(result.answer)
|
|
136
|
+
else:
|
|
137
|
+
st.markdown(result.answer)
|
|
138
|
+
|
|
139
|
+
st.subheader(f"Retrieved chunks ({len(scored)})")
|
|
140
|
+
if not scored:
|
|
141
|
+
st.info("No chunks were retrieved. Lower the min score or ingest a document.")
|
|
142
|
+
st.dataframe([
|
|
143
|
+
{"marker": f"S{i}", "score": round(score, 4), "file": d.metadata.get("source_file"),
|
|
144
|
+
"page": d.metadata.get("page_number"), "chunk": d.metadata.get("chunk_id"),
|
|
145
|
+
"chars": len(d.page_content)}
|
|
146
|
+
for i, (d, score) in enumerate(scored, start=1)
|
|
147
|
+
], hide_index=True, use_container_width=True)
|
|
148
|
+
for i, (doc, score) in enumerate(scored, start=1):
|
|
149
|
+
page = doc.metadata.get("page_number")
|
|
150
|
+
label = f"S{i} - {doc.metadata.get('source_file')}" + (f", page {page}" if page else "")
|
|
151
|
+
with st.expander(f"{label} (score {score:.3f})"):
|
|
152
|
+
st.text(doc.page_content)
|
|
153
|
+
st.json(doc.metadata, expanded=False)
|