persian-rag 0.2.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.
@@ -0,0 +1,121 @@
1
+ """Persian-first retrieval-augmented generation.
2
+
3
+ Normalize Farsi text, split it into chunks, and retrieve passages with a
4
+ hybrid of BM25 and dense vectors, optionally rerank them, and evaluate the
5
+ results. Generation is delegated to any callable.
6
+
7
+ Server-backed vector stores live in :mod:`persian_rag.vectorstores`, PDF and
8
+ DOCX loaders in :mod:`persian_rag.loaders`, and the HTTP API in
9
+ :mod:`persian_rag.server`; their dependencies are optional extras.
10
+ """
11
+
12
+ from persian_rag.bm25 import BM25Index
13
+ from persian_rag.cache import (
14
+ CachedEmbedder,
15
+ InMemoryEmbeddingCache,
16
+ LRUCache,
17
+ SQLiteEmbeddingCache,
18
+ )
19
+ from persian_rag.chunk import Chunk, ChunkingConfig, chunk_text
20
+ from persian_rag.documents import SourceDocument, load_documents, strip_markdown
21
+ from persian_rag.embeddings import (
22
+ Embedder,
23
+ EmbedderSpec,
24
+ HashingEmbedder,
25
+ OpenAICompatibleEmbedder,
26
+ SentenceTransformerEmbedder,
27
+ create_embedder,
28
+ )
29
+ from persian_rag.errors import (
30
+ ConfigError,
31
+ IndexFormatError,
32
+ IndexNotFoundError,
33
+ MissingDependencyError,
34
+ PersianRagError,
35
+ )
36
+ from persian_rag.evaluation import (
37
+ EvalExample,
38
+ LLMFaithfulnessJudge,
39
+ RetrievalReport,
40
+ TokenOverlapFaithfulness,
41
+ evaluate_answers,
42
+ evaluate_retrieval,
43
+ load_eval_examples,
44
+ )
45
+ from persian_rag.loaders import clean_extracted_text, load_docx, load_pdf
46
+ from persian_rag.normalize import NormalizeConfig, normalize
47
+ from persian_rag.persist import SavedIndex, load_index, retriever_from_saved, save_index
48
+ from persian_rag.pipeline import (
49
+ DEFAULT_PROMPT_EN,
50
+ DEFAULT_PROMPT_FA,
51
+ RAGAnswer,
52
+ RAGPipeline,
53
+ build_retriever,
54
+ )
55
+ from persian_rag.remote import make_openai_llm
56
+ from persian_rag.rerank import CrossEncoderReranker, LLMReranker, Reranker
57
+ from persian_rag.retrieval import HybridRetriever, RetrievalResult, reciprocal_rank_fusion
58
+ from persian_rag.store import FaissVectorStore, InMemoryVectorStore, VectorHit, VectorStore
59
+ from persian_rag.tokenize import split_sentence_spans, split_sentences, tokenize
60
+ from persian_rag.version import __version__
61
+
62
+ __all__ = [
63
+ "DEFAULT_PROMPT_EN",
64
+ "DEFAULT_PROMPT_FA",
65
+ "BM25Index",
66
+ "CachedEmbedder",
67
+ "Chunk",
68
+ "ChunkingConfig",
69
+ "ConfigError",
70
+ "CrossEncoderReranker",
71
+ "Embedder",
72
+ "EmbedderSpec",
73
+ "EvalExample",
74
+ "FaissVectorStore",
75
+ "HashingEmbedder",
76
+ "HybridRetriever",
77
+ "InMemoryEmbeddingCache",
78
+ "InMemoryVectorStore",
79
+ "IndexFormatError",
80
+ "IndexNotFoundError",
81
+ "LLMFaithfulnessJudge",
82
+ "LLMReranker",
83
+ "LRUCache",
84
+ "MissingDependencyError",
85
+ "NormalizeConfig",
86
+ "OpenAICompatibleEmbedder",
87
+ "PersianRagError",
88
+ "RAGAnswer",
89
+ "RAGPipeline",
90
+ "Reranker",
91
+ "RetrievalReport",
92
+ "RetrievalResult",
93
+ "SQLiteEmbeddingCache",
94
+ "SavedIndex",
95
+ "SentenceTransformerEmbedder",
96
+ "SourceDocument",
97
+ "TokenOverlapFaithfulness",
98
+ "VectorHit",
99
+ "VectorStore",
100
+ "__version__",
101
+ "build_retriever",
102
+ "chunk_text",
103
+ "clean_extracted_text",
104
+ "create_embedder",
105
+ "evaluate_answers",
106
+ "evaluate_retrieval",
107
+ "load_documents",
108
+ "load_docx",
109
+ "load_eval_examples",
110
+ "load_index",
111
+ "load_pdf",
112
+ "make_openai_llm",
113
+ "normalize",
114
+ "reciprocal_rank_fusion",
115
+ "retriever_from_saved",
116
+ "save_index",
117
+ "split_sentence_spans",
118
+ "split_sentences",
119
+ "strip_markdown",
120
+ "tokenize",
121
+ ]
persian_rag/bm25.py ADDED
@@ -0,0 +1,92 @@
1
+ """Okapi BM25 over in-memory tokenized documents.
2
+
3
+ The IDF term follows the non-negative Lucene variant:
4
+
5
+ ``log(1 + (N - df + 0.5) / (df + 0.5))``
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Sequence
12
+
13
+
14
+ class BM25Index:
15
+ """Sparse lexical index. Duplicate document ids are rejected."""
16
+
17
+ def __init__(self, *, k1: float = 1.5, b: float = 0.75) -> None:
18
+ if k1 <= 0:
19
+ raise ValueError("k1 must be > 0")
20
+ if not 0.0 <= b <= 1.0:
21
+ raise ValueError("b must be between 0 and 1")
22
+ self.k1 = k1
23
+ self.b = b
24
+ self._ids: list[str] = []
25
+ self._positions: dict[str, int] = {}
26
+ self._term_freqs: list[dict[str, int]] = []
27
+ self._lengths: list[int] = []
28
+ self._df: dict[str, int] = {}
29
+ self._avgdl = 0.0
30
+
31
+ def __len__(self) -> int:
32
+ return len(self._ids)
33
+
34
+ def add(self, ids: Sequence[str], documents: Sequence[Sequence[str]]) -> None:
35
+ """Index tokenized documents aligned with ``ids``."""
36
+
37
+ if len(ids) != len(documents):
38
+ raise ValueError("ids and documents must have the same length")
39
+ if len(set(ids)) != len(list(ids)):
40
+ raise ValueError("ids must be unique")
41
+ for doc_id, tokens in zip(ids, documents, strict=True):
42
+ if doc_id in self._positions:
43
+ raise ValueError(f"Duplicate document id: {doc_id}")
44
+ frequencies: dict[str, int] = {}
45
+ for token in tokens:
46
+ frequencies[token] = frequencies.get(token, 0) + 1
47
+ position = len(self._ids)
48
+ self._positions[doc_id] = position
49
+ self._ids.append(doc_id)
50
+ self._term_freqs.append(frequencies)
51
+ self._lengths.append(len(list(tokens)))
52
+ for token in frequencies:
53
+ self._df[token] = self._df.get(token, 0) + 1
54
+ self._recompute_avgdl()
55
+
56
+ def search(self, query_tokens: Sequence[str], k: int) -> list[tuple[str, float]]:
57
+ """Return up to ``k`` ``(doc_id, score)`` pairs, best score first."""
58
+
59
+ if k < 1:
60
+ raise ValueError("k must be >= 1")
61
+ if not self._ids or not query_tokens:
62
+ return []
63
+
64
+ total = len(self._ids)
65
+ scores = [0.0] * total
66
+ average = self._avgdl or 1.0
67
+ for token in dict.fromkeys(query_tokens):
68
+ df = self._df.get(token, 0)
69
+ if df == 0:
70
+ continue
71
+ idf = math.log(1.0 + (total - df + 0.5) / (df + 0.5))
72
+ for position, frequencies in enumerate(self._term_freqs):
73
+ freq = frequencies.get(token, 0)
74
+ if freq == 0:
75
+ continue
76
+ length_norm = 1.0 - self.b + self.b * self._lengths[position] / average
77
+ denominator = freq + self.k1 * length_norm
78
+ scores[position] += idf * (freq * (self.k1 + 1.0)) / denominator
79
+
80
+ ranked = [
81
+ (self._ids[position], scores[position])
82
+ for position in range(total)
83
+ if scores[position] > 0.0
84
+ ]
85
+ ranked.sort(key=lambda item: (-item[1], item[0]))
86
+ return ranked[:k]
87
+
88
+ def _recompute_avgdl(self) -> None:
89
+ if not self._lengths:
90
+ self._avgdl = 0.0
91
+ return
92
+ self._avgdl = sum(self._lengths) / len(self._lengths)
persian_rag/cache.py ADDED
@@ -0,0 +1,252 @@
1
+ """Embedding and retrieval caches.
2
+
3
+ * :class:`CachedEmbedder` wraps any :class:`~persian_rag.embeddings.Embedder`
4
+ and reuses vectors for texts it has already embedded.
5
+ * :class:`LRUCache` is a small thread-safe in-memory cache with optional
6
+ expiry. :class:`HybridRetriever` uses it for query results.
7
+ * :class:`SQLiteEmbeddingCache` persists vectors on disk with only the
8
+ standard library, so repeated ingestion runs skip paid embedding calls.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import sqlite3
16
+ import struct
17
+ import threading
18
+ import time
19
+ from collections import OrderedDict
20
+ from collections.abc import Callable, Sequence
21
+ from pathlib import Path
22
+ from typing import Any, Generic, Protocol, TypeVar, runtime_checkable
23
+
24
+ from persian_rag.embeddings import Embedder, EmbedderSpec
25
+
26
+ V = TypeVar("V")
27
+
28
+
29
+ class LRUCache(Generic[V]):
30
+ """Thread-safe least-recently-used cache with an optional time to live.
31
+
32
+ ``ttl`` is in seconds; ``None`` keeps entries until they are evicted.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ max_size: int = 1024,
38
+ ttl: float | None = None,
39
+ *,
40
+ clock: Callable[[], float] = time.monotonic,
41
+ ) -> None:
42
+ if max_size < 1:
43
+ raise ValueError("max_size must be >= 1")
44
+ if ttl is not None and ttl <= 0:
45
+ raise ValueError("ttl must be > 0")
46
+ self.max_size = max_size
47
+ self.ttl = ttl
48
+ self._clock = clock
49
+ self._data: OrderedDict[str, tuple[float, V]] = OrderedDict()
50
+ self._lock = threading.Lock()
51
+ self.hits = 0
52
+ self.misses = 0
53
+
54
+ def get(self, key: str) -> V | None:
55
+ with self._lock:
56
+ entry = self._data.get(key)
57
+ if entry is None:
58
+ self.misses += 1
59
+ return None
60
+ stored_at, value = entry
61
+ if self.ttl is not None and self._clock() - stored_at > self.ttl:
62
+ del self._data[key]
63
+ self.misses += 1
64
+ return None
65
+ self._data.move_to_end(key)
66
+ self.hits += 1
67
+ return value
68
+
69
+ def set(self, key: str, value: V) -> None:
70
+ with self._lock:
71
+ self._data[key] = (self._clock(), value)
72
+ self._data.move_to_end(key)
73
+ while len(self._data) > self.max_size:
74
+ self._data.popitem(last=False)
75
+
76
+ def clear(self) -> None:
77
+ with self._lock:
78
+ self._data.clear()
79
+
80
+ def __len__(self) -> int:
81
+ return len(self._data)
82
+
83
+ def __contains__(self, key: object) -> bool:
84
+ return key in self._data
85
+
86
+
87
+ @runtime_checkable
88
+ class EmbeddingCache(Protocol):
89
+ """Key-value storage for embedding vectors."""
90
+
91
+ def get(self, key: str) -> list[float] | None: ...
92
+
93
+ def set(self, key: str, value: list[float]) -> None: ...
94
+
95
+
96
+ class InMemoryEmbeddingCache(LRUCache[list[float]]):
97
+ """In-process embedding cache. Defaults to 100,000 vectors."""
98
+
99
+ def __init__(self, max_size: int = 100_000, ttl: float | None = None) -> None:
100
+ super().__init__(max_size=max_size, ttl=ttl)
101
+
102
+
103
+ class SQLiteEmbeddingCache:
104
+ """Persistent embedding cache in a single SQLite file.
105
+
106
+ Vectors are stored as packed float64 values. The connection is shared
107
+ between threads and guarded by a lock.
108
+ """
109
+
110
+ def __init__(self, path: str | Path) -> None:
111
+ self.path = Path(path)
112
+ if str(path) != ":memory:":
113
+ self.path.parent.mkdir(parents=True, exist_ok=True)
114
+ self._lock = threading.Lock()
115
+ self._conn = sqlite3.connect(str(path), check_same_thread=False)
116
+ with self._lock:
117
+ self._conn.execute(
118
+ "CREATE TABLE IF NOT EXISTS embeddings (key TEXT PRIMARY KEY, vector BLOB NOT NULL)"
119
+ )
120
+ self._conn.commit()
121
+
122
+ def get(self, key: str) -> list[float] | None:
123
+ with self._lock:
124
+ row = self._conn.execute(
125
+ "SELECT vector FROM embeddings WHERE key = ?", (key,)
126
+ ).fetchone()
127
+ if row is None:
128
+ return None
129
+ blob = bytes(row[0])
130
+ return list(struct.unpack(f"<{len(blob) // 8}d", blob))
131
+
132
+ def set(self, key: str, value: list[float]) -> None:
133
+ blob = struct.pack(f"<{len(value)}d", *value)
134
+ with self._lock:
135
+ self._conn.execute(
136
+ "INSERT OR REPLACE INTO embeddings (key, vector) VALUES (?, ?)", (key, blob)
137
+ )
138
+ self._conn.commit()
139
+
140
+ def __len__(self) -> int:
141
+ with self._lock:
142
+ row = self._conn.execute("SELECT count(*) FROM embeddings").fetchone()
143
+ return int(row[0])
144
+
145
+ def clear(self) -> None:
146
+ with self._lock:
147
+ self._conn.execute("DELETE FROM embeddings")
148
+ self._conn.commit()
149
+
150
+ def close(self) -> None:
151
+ with self._lock:
152
+ self._conn.close()
153
+
154
+ def __enter__(self) -> SQLiteEmbeddingCache:
155
+ return self
156
+
157
+ def __exit__(self, *exc: object) -> None:
158
+ self.close()
159
+
160
+
161
+ def embedder_namespace(embedder: Embedder) -> str:
162
+ """A stable cache namespace for an embedder.
163
+
164
+ Uses the embedder's :class:`EmbedderSpec` when it has one, so changing the
165
+ model, dimension, or hashing settings never returns stale vectors.
166
+ """
167
+
168
+ to_spec = getattr(embedder, "to_spec", None)
169
+ if callable(to_spec):
170
+ spec = to_spec()
171
+ if isinstance(spec, EmbedderSpec):
172
+ return json.dumps(spec.to_dict(), sort_keys=True, ensure_ascii=False)
173
+ return f"{type(embedder).__module__}.{type(embedder).__qualname__}:{embedder.dimension}"
174
+
175
+
176
+ class CachedEmbedder:
177
+ """Wrap an embedder and cache its vectors.
178
+
179
+ Document and query vectors are cached separately because some models
180
+ (for example E5) embed them differently. Only missing texts are sent to
181
+ the wrapped embedder, in one batch.
182
+ """
183
+
184
+ def __init__(
185
+ self,
186
+ embedder: Embedder,
187
+ cache: EmbeddingCache | None = None,
188
+ *,
189
+ namespace: str | None = None,
190
+ ) -> None:
191
+ self.embedder = embedder
192
+ self.cache: EmbeddingCache = cache if cache is not None else InMemoryEmbeddingCache()
193
+ self.namespace = namespace if namespace is not None else embedder_namespace(embedder)
194
+
195
+ @property
196
+ def dimension(self) -> int:
197
+ return self.embedder.dimension
198
+
199
+ def to_spec(self) -> EmbedderSpec:
200
+ """Return the wrapped embedder's spec so indexes stay portable."""
201
+
202
+ to_spec = getattr(self.embedder, "to_spec", None)
203
+ if not callable(to_spec):
204
+ raise AttributeError(f"{type(self.embedder).__name__} has no to_spec()")
205
+ spec = to_spec()
206
+ if not isinstance(spec, EmbedderSpec):
207
+ raise TypeError("to_spec() must return an EmbedderSpec")
208
+ return spec
209
+
210
+ def key(self, text: str, kind: str = "doc") -> str:
211
+ digest = hashlib.sha256()
212
+ for part in (self.namespace, kind, text):
213
+ digest.update(part.encode("utf-8"))
214
+ digest.update(b"\x00")
215
+ return digest.hexdigest()
216
+
217
+ def embed_documents(self, texts: Sequence[str]) -> list[list[float]]:
218
+ keys = [self.key(text) for text in texts]
219
+ found: dict[int, list[float]] = {}
220
+ missing: dict[str, list[int]] = {}
221
+ for index, (text, key) in enumerate(zip(texts, keys, strict=True)):
222
+ cached = self.cache.get(key)
223
+ if cached is not None:
224
+ found[index] = list(cached)
225
+ else:
226
+ missing.setdefault(text, []).append(index)
227
+ if missing:
228
+ todo = list(missing)
229
+ vectors = self.embedder.embed_documents(todo)
230
+ if len(vectors) != len(todo):
231
+ raise ValueError("embedder returned an unexpected number of vectors")
232
+ for text, vector in zip(todo, vectors, strict=True):
233
+ values = [float(value) for value in vector]
234
+ self.cache.set(self.key(text), values)
235
+ for index in missing[text]:
236
+ found[index] = list(values)
237
+ return [found[index] for index in range(len(texts))]
238
+
239
+ def embed_query(self, text: str) -> list[float]:
240
+ key = self.key(text, "query")
241
+ cached = self.cache.get(key)
242
+ if cached is not None:
243
+ return list(cached)
244
+ vector = [float(value) for value in self.embedder.embed_query(text)]
245
+ self.cache.set(key, vector)
246
+ return vector
247
+
248
+
249
+ def result_cache_key(*parts: Any) -> str:
250
+ """Build a cache key from JSON-serializable parts."""
251
+
252
+ return json.dumps(parts, ensure_ascii=False, sort_keys=True, default=str)