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.
- persian_rag/__init__.py +121 -0
- persian_rag/bm25.py +92 -0
- persian_rag/cache.py +252 -0
- persian_rag/chunk.py +340 -0
- persian_rag/cli.py +454 -0
- persian_rag/documents.py +155 -0
- persian_rag/embeddings.py +352 -0
- persian_rag/errors.py +23 -0
- persian_rag/evaluation.py +525 -0
- persian_rag/loaders.py +269 -0
- persian_rag/normalize.py +212 -0
- persian_rag/persist.py +185 -0
- persian_rag/pipeline.py +276 -0
- persian_rag/py.typed +0 -0
- persian_rag/remote.py +181 -0
- persian_rag/rerank.py +255 -0
- persian_rag/retrieval.py +276 -0
- persian_rag/server.py +133 -0
- persian_rag/store.py +280 -0
- persian_rag/tokenize.py +192 -0
- persian_rag/vectorstores/__init__.py +39 -0
- persian_rag/vectorstores/pgvector.py +186 -0
- persian_rag/vectorstores/qdrant.py +195 -0
- persian_rag/version.py +4 -0
- persian_rag-0.2.0.dist-info/METADATA +347 -0
- persian_rag-0.2.0.dist-info/RECORD +29 -0
- persian_rag-0.2.0.dist-info/WHEEL +4 -0
- persian_rag-0.2.0.dist-info/entry_points.txt +2 -0
- persian_rag-0.2.0.dist-info/licenses/LICENSE +21 -0
persian_rag/__init__.py
ADDED
|
@@ -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)
|