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/env.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Explicitly copy selected .env values into os.environ.
|
|
2
|
+
|
|
3
|
+
`Settings.load()` never touches os.environ. Some libraries, however, only read
|
|
4
|
+
their configuration from the environment - Hugging Face reads HF_HOME when it
|
|
5
|
+
is first imported, and HTTP clients read proxy variables - so a value placed
|
|
6
|
+
in the project's .env would otherwise be ignored.
|
|
7
|
+
|
|
8
|
+
`export_env_file(settings)` closes that gap with an allow-list. Only the
|
|
9
|
+
application entry points call it: the CLI, the default API app (`main.app`)
|
|
10
|
+
at startup, and the Streamlit UI. Importing the package, building a Pipeline
|
|
11
|
+
or calling create_app() never exports anything.
|
|
12
|
+
|
|
13
|
+
Policy:
|
|
14
|
+
* Exported: variables other libraries read from the environment (see
|
|
15
|
+
EXPORT_PREFIXES / EXPORT_NAMES), plus any names listed in RAG_EXPORT_ENV
|
|
16
|
+
(comma-separated, in .env or the environment).
|
|
17
|
+
* Never exported: rag-kit's own settings, including GOOGLE_API_KEY and
|
|
18
|
+
QDRANT_API_KEY, and any RAG_* variable. Settings reads those directly, and
|
|
19
|
+
server/UI child processes re-read .env themselves.
|
|
20
|
+
* Variables already set in the process win; empty values are skipped.
|
|
21
|
+
* Only names are ever logged or returned, never values. HF_TOKEN is a secret
|
|
22
|
+
but is exported, because huggingface_hub reads it only from the environment.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
from typing import List, MutableMapping, Optional
|
|
28
|
+
|
|
29
|
+
from dotenv import dotenv_values
|
|
30
|
+
|
|
31
|
+
from rag_kit.settings import SETTING_NAMES, Settings
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
OPT_IN_VARIABLE = "RAG_EXPORT_ENV"
|
|
36
|
+
|
|
37
|
+
# Hugging Face, transformers, sentence-transformers and torch runtime settings.
|
|
38
|
+
EXPORT_PREFIXES = ("HF_", "HUGGINGFACE_", "TRANSFORMERS_", "SENTENCE_TRANSFORMERS_", "TORCH_")
|
|
39
|
+
EXPORT_NAMES = frozenset({
|
|
40
|
+
"TOKENIZERS_PARALLELISM", "CUDA_VISIBLE_DEVICES", "OMP_NUM_THREADS",
|
|
41
|
+
"REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "SSL_CERT_DIR", "CURL_CA_BUNDLE",
|
|
42
|
+
})
|
|
43
|
+
# Proxy variables are honoured in either case by most HTTP clients.
|
|
44
|
+
PROXY_NAMES = frozenset({"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY"})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_rag_kit_setting(name: str) -> bool:
|
|
48
|
+
return name in SETTING_NAMES or name.upper().startswith("RAG_")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _allow_listed(name: str) -> bool:
|
|
52
|
+
return (name in EXPORT_NAMES or name.upper() in PROXY_NAMES
|
|
53
|
+
or name.startswith(EXPORT_PREFIXES))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _opt_in_names(file_values: dict, environ: MutableMapping[str, str]) -> set:
|
|
57
|
+
raw = environ.get(OPT_IN_VARIABLE) or file_values.get(OPT_IN_VARIABLE) or ""
|
|
58
|
+
return {name.strip() for name in raw.split(",") if name.strip()}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def export_env_file(settings: Settings, environ: Optional[MutableMapping[str, str]] = None,
|
|
62
|
+
override: bool = False) -> List[str]:
|
|
63
|
+
"""Copy allow-listed and opted-in keys from settings.env_file into `environ`
|
|
64
|
+
(default: os.environ). Returns the sorted names that were set."""
|
|
65
|
+
environ = os.environ if environ is None else environ
|
|
66
|
+
file_values = dotenv_values(settings.env_file)
|
|
67
|
+
opt_in = _opt_in_names(file_values, environ)
|
|
68
|
+
|
|
69
|
+
refused = sorted(name for name in opt_in if is_rag_kit_setting(name))
|
|
70
|
+
if refused:
|
|
71
|
+
logger.warning(f" ! {OPT_IN_VARIABLE} lists rag-kit settings, which are never "
|
|
72
|
+
f"exported: {', '.join(refused)}")
|
|
73
|
+
|
|
74
|
+
exported = []
|
|
75
|
+
for name, value in file_values.items():
|
|
76
|
+
if not value or is_rag_kit_setting(name):
|
|
77
|
+
continue
|
|
78
|
+
if not (_allow_listed(name) or name in opt_in):
|
|
79
|
+
continue
|
|
80
|
+
if name in environ and not override:
|
|
81
|
+
continue # the process environment wins
|
|
82
|
+
environ[name] = value
|
|
83
|
+
exported.append(name)
|
|
84
|
+
return sorted(exported)
|
rag_kit/errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Errors the pipeline raises on purpose.
|
|
2
|
+
|
|
3
|
+
Every message says what went wrong *and* what to do about it, so the CLI, the
|
|
4
|
+
FastAPI service and the Streamlit UI can show it to the user as-is.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RagError(RuntimeError):
|
|
9
|
+
"""Base class for expected, user-fixable failures."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigError(RagError):
|
|
13
|
+
"""A setting is missing or invalid. The message says how to fix it."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnsupportedFileError(RagError):
|
|
17
|
+
"""Step 1: the input is missing, empty, or not a supported type."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ExtractionError(RagError):
|
|
21
|
+
"""Step 2: the file could not be opened or read."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EmptyDocumentError(RagError):
|
|
25
|
+
"""Steps 2-4: the file was read but produced no usable text."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EmbeddingProviderError(RagError):
|
|
29
|
+
"""Step 5: the embedding model could not be loaded or called."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class VectorStoreError(RagError):
|
|
33
|
+
"""Step 6/9: Qdrant is unreachable, locked, missing, or incompatible."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GenerationError(RagError):
|
|
37
|
+
"""Step 10: the LLM could not be created or called."""
|
rag_kit/log.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Step progress goes through logging, not print().
|
|
2
|
+
|
|
3
|
+
Library code logs to the "rag_kit" logger, which has only a NullHandler, so a
|
|
4
|
+
program that imports the pipeline sees no output unless it asks for it.
|
|
5
|
+
Applications (the CLI, the API service, the Streamlit UI) call
|
|
6
|
+
`show_progress()` to print the same "[1] Document : ..." lines as before.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
LOGGER_NAME = "rag_kit" # rag_kit/__init__.py gives it a NullHandler
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _CurrentStdout(logging.StreamHandler):
|
|
16
|
+
"""Writes to whatever sys.stdout is when a record is emitted, so
|
|
17
|
+
contextlib.redirect_stdout (the CLI's --json mode) and test capture still
|
|
18
|
+
apply - exactly like the print() calls this replaces."""
|
|
19
|
+
|
|
20
|
+
def __init__(self):
|
|
21
|
+
super().__init__(sys.stdout)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def stream(self):
|
|
25
|
+
return sys.stdout
|
|
26
|
+
|
|
27
|
+
@stream.setter
|
|
28
|
+
def stream(self, _value):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def show_progress(level: int = logging.INFO) -> logging.Handler:
|
|
33
|
+
"""Print step progress (message text only) to stdout. Safe to call twice."""
|
|
34
|
+
logger = logging.getLogger(LOGGER_NAME)
|
|
35
|
+
for handler in [h for h in logger.handlers if isinstance(h, _CurrentStdout)]:
|
|
36
|
+
logger.removeHandler(handler)
|
|
37
|
+
handler = _CurrentStdout()
|
|
38
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
39
|
+
logger.addHandler(handler)
|
|
40
|
+
logger.setLevel(level)
|
|
41
|
+
logger.propagate = False # do not print twice if the root logger is configured
|
|
42
|
+
return handler
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Font-encoding repair for PDFs with broken glyph maps.
|
|
2
|
+
|
|
3
|
+
Some PDFs (this project's `-Free-English-Grammar.pdf` among them - watermarked by
|
|
4
|
+
pdforall.com) embed subset fonts with *no* ToUnicode CMap. Every PDF text
|
|
5
|
+
extractor - pypdf and PyMuPDF alike - then returns the raw glyph indices, which
|
|
6
|
+
come out as `& ? ! & & ? !` garbage.
|
|
7
|
+
|
|
8
|
+
The scramble is a plain, document-wide substitution cipher, so it is fully
|
|
9
|
+
recoverable without OCR. Two subset fonts carry the body:
|
|
10
|
+
|
|
11
|
+
TTE1AEB1B8t00 regular body text (~93 % of characters)
|
|
12
|
+
TTE1AEB0C8t00 bold headings / emphasised grammar terms
|
|
13
|
+
|
|
14
|
+
The maps below were reconstructed by decoding known words. Anything that is not
|
|
15
|
+
one of those two fonts (Times-Roman digits, the Helvetica watermark) already
|
|
16
|
+
extracts correctly and is passed through untouched.
|
|
17
|
+
|
|
18
|
+
`repair_page` rebuilds a page's text span by span, so page boundaries - and
|
|
19
|
+
therefore the page numbers cited in step 11 - stay exactly where PyMuPDF puts
|
|
20
|
+
them.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
|
|
27
|
+
BODY_FONT = "TTE1AEB1B8t00"
|
|
28
|
+
BOLD_FONT = "TTE1AEB0C8t00"
|
|
29
|
+
|
|
30
|
+
# --- regular body font -------------------------------------------------------
|
|
31
|
+
BODY_MAP = {
|
|
32
|
+
0x01: " ", 0x02: "b", 0x03: "y", 0x04: "S", 0x05: "e", 0x06: "c",
|
|
33
|
+
0x07: "o", 0x08: "n", 0x09: "d", 0x0A: "i", 0x0B: "t", 0x0C: ".",
|
|
34
|
+
0x0D: "C", 0x0E: "p", 0x0F: "r", 0x10: "g", 0x11: "h", 0x13: "2",
|
|
35
|
+
0x14: "0", 0x15: "T", 0x16: "m", 0x17: "a", 0x18: "l", 0x19: "s",
|
|
36
|
+
0x1A: "k", 0x1B: "u", 0x1C: "f", 0x1F: "w", 0x20: "v",
|
|
37
|
+
0x21: ",", 0x22: "x", 0x23: "I", 0x24: ":", 0x26: "A", 0x27: "E",
|
|
38
|
+
0x28: "F", 0x29: "G", 0x2A: "N", 0x2B: "z", 0x2C: "H", 0x2D: "U",
|
|
39
|
+
0x2E: "D", 0x2F: "O", 0x30: "R", 0x31: "M", 0x32: "L", 0x33: "V",
|
|
40
|
+
0x34: "B", 0x35: "W", 0x36: "P", 0x37: "1", 0x38: "3", 0x39: "4",
|
|
41
|
+
0x3A: "Q", 0x3B: "q", 0x3C: "5", 0x3D: "6", 0x3E: "7", 0x3F: "j",
|
|
42
|
+
0x40: "8", 0x42: "9", 0x43: "/", 0x44: "(", 0x45: ")", 0x46: "?",
|
|
43
|
+
0x47: "’", 0x48: "Y", 0x49: " ", 0x4A: '"', 0x4B: '"', 0x4C: "J",
|
|
44
|
+
0x4E: "!", 0x25: "•",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# --- bold headings / emphasis font ----------------------------------------
|
|
48
|
+
BOLD_MAP = {
|
|
49
|
+
0x01: " ", 0x02: "s", 0x03: "e", 0x04: "y", 0x05: "f", 0x06: "i",
|
|
50
|
+
0x08: "o", 0x09: "c", 0x0A: "a", 0x0B: "w", 0x0D: "s", 0x0E: "h",
|
|
51
|
+
0x0F: "m", 0x11: "n", 0x13: "l", 0x15: "r", 0x1A: "a", 0x1F: "t",
|
|
52
|
+
0x22: "C", 0x24: "f", 0x26: "t", 0x27: "b", 0x29: "v", 0x2C: "d",
|
|
53
|
+
0x30: "u", 0x36: "p", 0x37: "’", 0x38: "W", 0x39: ",", 0x3A: "u",
|
|
54
|
+
0x3B: "v", 0x3C: "j", 0x3E: "k", 0x3F: "Q", 0x41: "Q", 0x46: ")",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Capital glyphs the font draws as "<capital><phantom 0x20>"; the trailing
|
|
58
|
+
# glyph 0x20 (otherwise 'v') must be swallowed.
|
|
59
|
+
_CAP_ABSORB_V = {0x29, 0x2C, 0x2D, 0x2F, 0x31}
|
|
60
|
+
|
|
61
|
+
# characters worth keeping verbatim when a glyph is not in the map
|
|
62
|
+
_KEEP = set(" .,;:!?()[]{}\"'‘’“”-–—/&%$#@*+=<>0123456789\n\t")
|
|
63
|
+
|
|
64
|
+
_PHANTOM_V = re.compile(r"\b([HOMGU])v(?=[a-z])")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _decode_span(raw: str, fmap: dict, absorb_v: bool) -> str:
|
|
68
|
+
out = []
|
|
69
|
+
i, n = 0, len(raw)
|
|
70
|
+
while i < n:
|
|
71
|
+
cp = ord(raw[i])
|
|
72
|
+
if cp in fmap:
|
|
73
|
+
out.append(fmap[cp])
|
|
74
|
+
if absorb_v and cp in _CAP_ABSORB_V and i + 1 < n and ord(raw[i + 1]) == 0x20:
|
|
75
|
+
i += 1 # swallow the phantom 'v' that trails this capital
|
|
76
|
+
elif cp < 0x20:
|
|
77
|
+
pass # unmapped control glyph - drop it rather than emit noise
|
|
78
|
+
elif raw[i] in _KEEP or raw[i].isalnum():
|
|
79
|
+
out.append(raw[i])
|
|
80
|
+
i += 1
|
|
81
|
+
return "".join(out)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def repair_page(page) -> str:
|
|
85
|
+
"""Return the readable text of one ``pymupdf`` page.
|
|
86
|
+
|
|
87
|
+
Broken subset fonts are decoded via the substitution maps above; every other
|
|
88
|
+
font is passed through as PyMuPDF extracted it.
|
|
89
|
+
"""
|
|
90
|
+
data = page.get_text("rawdict")
|
|
91
|
+
lines = []
|
|
92
|
+
for block in data["blocks"]:
|
|
93
|
+
for line in block.get("lines", []):
|
|
94
|
+
buf = []
|
|
95
|
+
for span in line["spans"]:
|
|
96
|
+
raw = "".join(ch["c"] for ch in span["chars"])
|
|
97
|
+
font = span["font"]
|
|
98
|
+
if font == BODY_FONT:
|
|
99
|
+
buf.append(_decode_span(raw, BODY_MAP, absorb_v=True))
|
|
100
|
+
elif font == BOLD_FONT:
|
|
101
|
+
buf.append(_decode_span(raw, BOLD_MAP, absorb_v=False))
|
|
102
|
+
else:
|
|
103
|
+
buf.append(raw)
|
|
104
|
+
lines.append("".join(buf))
|
|
105
|
+
return _PHANTOM_V.sub(r"\1", "\n".join(lines))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --- quality scoring -------------------------------------------------------
|
|
109
|
+
_WORD = re.compile(r"[A-Za-z][A-Za-z'’-]*")
|
|
110
|
+
_VOWEL = re.compile(r"[aeiouyAEIOUY]")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def text_quality(text: str) -> tuple[float, float]:
|
|
114
|
+
"""(alphabetic-character ratio, plausible-English-word ratio) for `text`."""
|
|
115
|
+
stripped = [c for c in text if not c.isspace()]
|
|
116
|
+
if not stripped:
|
|
117
|
+
return 0.0, 0.0
|
|
118
|
+
alpha_ratio = sum(c.isalpha() for c in stripped) / len(stripped)
|
|
119
|
+
|
|
120
|
+
words = _WORD.findall(text)
|
|
121
|
+
if not words:
|
|
122
|
+
return alpha_ratio, 0.0
|
|
123
|
+
good = sum(
|
|
124
|
+
1 for w in words
|
|
125
|
+
if 1 <= len(w) <= 20 and (len(w) <= 2 or _VOWEL.search(w))
|
|
126
|
+
)
|
|
127
|
+
return alpha_ratio, good / len(words)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_readable(text: str, min_alpha: float = 0.70, min_words: float = 0.80) -> bool:
|
|
131
|
+
alpha_ratio, word_ratio = text_quality(text)
|
|
132
|
+
return alpha_ratio >= min_alpha and word_ratio >= min_words
|
rag_kit/pipeline.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""The 11 steps wired together, shared by the CLI, FastAPI and Streamlit.
|
|
2
|
+
|
|
3
|
+
pipeline = Pipeline() # nothing is loaded yet
|
|
4
|
+
pipeline.ingest("data/book.pdf") # steps 1-6
|
|
5
|
+
result = pipeline.ask("What is a noun?") # steps 7-11 (minus printing)
|
|
6
|
+
pipeline.close()
|
|
7
|
+
|
|
8
|
+
The embedding model, Qdrant client and LLM are created on first use and then
|
|
9
|
+
reused. A local Qdrant store allows one client per process, so keep one
|
|
10
|
+
Pipeline per process.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
from langchain_core.documents import Document
|
|
19
|
+
|
|
20
|
+
from rag_kit.errors import EmptyDocumentError
|
|
21
|
+
from rag_kit.settings import Settings, default_settings
|
|
22
|
+
from rag_kit.step01_load_pdf import load_document
|
|
23
|
+
from rag_kit.step02_extract_text import extract_text
|
|
24
|
+
from rag_kit.step03_clean_text import clean_documents
|
|
25
|
+
from rag_kit.step04_chunking import chunk_documents
|
|
26
|
+
from rag_kit.step05_embedding import get_embeddings
|
|
27
|
+
from rag_kit.step06_vector_store import (
|
|
28
|
+
delete_document,
|
|
29
|
+
get_client,
|
|
30
|
+
list_documents,
|
|
31
|
+
open_vector_store,
|
|
32
|
+
store_chunks,
|
|
33
|
+
)
|
|
34
|
+
from rag_kit.step08_query_embedding import embed_question
|
|
35
|
+
from rag_kit.step09_retrieve import retrieve
|
|
36
|
+
from rag_kit.step10_generate import NO_RESULTS_ANSWER, generate_answer, get_llm
|
|
37
|
+
from rag_kit.step11_answer import build_sources, strip_markers
|
|
38
|
+
|
|
39
|
+
ScoredDocs = List[Tuple[Document, float]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class AnswerResult:
|
|
44
|
+
question: str
|
|
45
|
+
answer: str # raw model output, with [S1] markers
|
|
46
|
+
status: str # "answered" or "no_results"
|
|
47
|
+
sources: List[dict] = field(default_factory=list)
|
|
48
|
+
scored_docs: ScoredDocs = field(default_factory=list, repr=False)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def answer_without_markers(self) -> str:
|
|
52
|
+
return strip_markers(self.answer)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Pipeline:
|
|
56
|
+
def __init__(self, collection: Optional[str] = None, embeddings=None, client=None, llm=None,
|
|
57
|
+
settings: Optional[Settings] = None):
|
|
58
|
+
self.settings = settings or default_settings()
|
|
59
|
+
self.collection = collection or self.settings.collection_name
|
|
60
|
+
self._embeddings = embeddings
|
|
61
|
+
self._client = client
|
|
62
|
+
self._llm = llm
|
|
63
|
+
self._vector_store = None
|
|
64
|
+
self._lock = threading.RLock()
|
|
65
|
+
|
|
66
|
+
# --- lazily created resources -------------------------------------------
|
|
67
|
+
@property
|
|
68
|
+
def embeddings(self):
|
|
69
|
+
with self._lock:
|
|
70
|
+
if self._embeddings is None:
|
|
71
|
+
self._embeddings = get_embeddings(self.settings) # 5
|
|
72
|
+
return self._embeddings
|
|
73
|
+
|
|
74
|
+
def client(self, create: bool = False):
|
|
75
|
+
with self._lock:
|
|
76
|
+
if self._client is None:
|
|
77
|
+
self._client = get_client(create=create, settings=self.settings)
|
|
78
|
+
return self._client
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def vector_store(self):
|
|
82
|
+
with self._lock:
|
|
83
|
+
if self._vector_store is None: # 6 (read side)
|
|
84
|
+
self._vector_store = open_vector_store(self.embeddings, self.client(),
|
|
85
|
+
self.collection, self.settings)
|
|
86
|
+
return self._vector_store
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def llm(self):
|
|
90
|
+
with self._lock:
|
|
91
|
+
if self._llm is None:
|
|
92
|
+
self._llm = get_llm(self.settings) # 10
|
|
93
|
+
return self._llm
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def ready(self) -> bool:
|
|
97
|
+
"""True once the collection has been opened and validated."""
|
|
98
|
+
return self._vector_store is not None
|
|
99
|
+
|
|
100
|
+
def warm_up(self, llm: bool = True) -> None:
|
|
101
|
+
"""Load everything a query needs, so the first request is not slow."""
|
|
102
|
+
_ = self.vector_store
|
|
103
|
+
if llm:
|
|
104
|
+
_ = self.llm
|
|
105
|
+
|
|
106
|
+
def close(self) -> None:
|
|
107
|
+
with self._lock:
|
|
108
|
+
if self._client is not None:
|
|
109
|
+
self._client.close()
|
|
110
|
+
self._client = self._vector_store = None
|
|
111
|
+
|
|
112
|
+
# --- steps 1-6 -----------------------------------------------------------
|
|
113
|
+
def ingest(self, path, recreate: bool = False, font_repair: Optional[bool] = None) -> dict:
|
|
114
|
+
source = load_document(path) # 1
|
|
115
|
+
pages = extract_text(source, font_repair=font_repair, # 2
|
|
116
|
+
settings=self.settings)
|
|
117
|
+
pages = clean_documents(pages) # 3
|
|
118
|
+
if not pages:
|
|
119
|
+
raise EmptyDocumentError(
|
|
120
|
+
f"No usable text in {source.name} after cleaning. If it is a scanned "
|
|
121
|
+
"PDF, run OCR first (e.g. `ocrmypdf in.pdf out.pdf`)."
|
|
122
|
+
)
|
|
123
|
+
chunks = chunk_documents(pages, self.settings.chunk_size, # 4
|
|
124
|
+
self.settings.chunk_overlap)
|
|
125
|
+
with self._lock:
|
|
126
|
+
report = store_chunks(chunks, self.embeddings, recreate=recreate, # 5 + 6
|
|
127
|
+
client=self.client(create=True), collection=self.collection,
|
|
128
|
+
settings=self.settings)
|
|
129
|
+
self._vector_store = None # re-validate on next read (collection may be new)
|
|
130
|
+
report["file"] = source.name
|
|
131
|
+
report["pages"] = len(pages)
|
|
132
|
+
return report
|
|
133
|
+
|
|
134
|
+
# --- steps 8-11 ----------------------------------------------------------
|
|
135
|
+
def search(self, question: str, k: Optional[int] = None,
|
|
136
|
+
min_score: Optional[float] = None) -> ScoredDocs:
|
|
137
|
+
question = _require_question(question)
|
|
138
|
+
query_vector = embed_question(self.embeddings, question) # 8
|
|
139
|
+
return retrieve(self.vector_store, query_vector, k or self.settings.top_k, # 9
|
|
140
|
+
self.settings.min_score if min_score is None else min_score)
|
|
141
|
+
|
|
142
|
+
def ask(self, question: str, k: Optional[int] = None,
|
|
143
|
+
min_score: Optional[float] = None) -> AnswerResult:
|
|
144
|
+
question = _require_question(question)
|
|
145
|
+
scored_docs = self.search(question, k=k, min_score=min_score)
|
|
146
|
+
if not scored_docs:
|
|
147
|
+
return AnswerResult(question, NO_RESULTS_ANSWER, "no_results")
|
|
148
|
+
answer = generate_answer(question, scored_docs, llm=self.llm, # 10
|
|
149
|
+
settings=self.settings)
|
|
150
|
+
return AnswerResult(question, answer, "answered", # 11
|
|
151
|
+
build_sources(scored_docs), scored_docs)
|
|
152
|
+
|
|
153
|
+
# --- maintenance ---------------------------------------------------------
|
|
154
|
+
def documents(self) -> dict:
|
|
155
|
+
return list_documents(self.client(), self.collection, self.settings)
|
|
156
|
+
|
|
157
|
+
def delete(self, source_file: str) -> int:
|
|
158
|
+
with self._lock:
|
|
159
|
+
removed = delete_document(self.client(), Path(source_file).name, self.collection,
|
|
160
|
+
self.settings)
|
|
161
|
+
self._vector_store = None
|
|
162
|
+
return removed
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _require_question(question: str) -> str:
|
|
166
|
+
question = (question or "").strip()
|
|
167
|
+
if not question:
|
|
168
|
+
raise ValueError("Question is empty.")
|
|
169
|
+
return question
|
rag_kit/py.typed
ADDED
|
File without changes
|