microrag 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.
- microrag/__init__.py +57 -0
- microrag/config.py +86 -0
- microrag/core.py +293 -0
- microrag/embedding/__init__.py +5 -0
- microrag/embedding/model.py +118 -0
- microrag/exceptions.py +29 -0
- microrag/models.py +45 -0
- microrag/query_processor.py +128 -0
- microrag/search/__init__.py +6 -0
- microrag/search/bm25.py +79 -0
- microrag/search/hybrid.py +234 -0
- microrag/stopwords.py +114 -0
- microrag/storage/__init__.py +6 -0
- microrag/storage/base.py +122 -0
- microrag/storage/duckdb.py +296 -0
- microrag/utils.py +151 -0
- microrag-0.1.0.dist-info/METADATA +341 -0
- microrag-0.1.0.dist-info/RECORD +20 -0
- microrag-0.1.0.dist-info/WHEEL +4 -0
- microrag-0.1.0.dist-info/licenses/LICENSE +21 -0
microrag/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""MicroRAG - A feature-rich, universal RAG library for Python.
|
|
2
|
+
|
|
3
|
+
MicroRAG provides:
|
|
4
|
+
- ONNX-backed embeddings (CPU-only, no PyTorch at runtime)
|
|
5
|
+
- DuckDB storage with HNSW vector indexes
|
|
6
|
+
- Three-tier hybrid search (semantic + BM25 + FTS) with RRF fusion
|
|
7
|
+
- Query preprocessing with abbreviation expansion
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
```python
|
|
11
|
+
from microrag import MicroRAG, RAGConfig
|
|
12
|
+
|
|
13
|
+
config = RAGConfig(
|
|
14
|
+
model_path="/path/to/all-MiniLM-L6-v2",
|
|
15
|
+
db_path="./rag.duckdb",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
with MicroRAG(config) as rag:
|
|
19
|
+
rag.add_documents(["Document 1", "Document 2"])
|
|
20
|
+
rag.build_index()
|
|
21
|
+
results = rag.search("query")
|
|
22
|
+
```
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from microrag.config import RAGConfig
|
|
26
|
+
from microrag.core import MicroRAG
|
|
27
|
+
from microrag.exceptions import (
|
|
28
|
+
ConfigurationError,
|
|
29
|
+
DocumentError,
|
|
30
|
+
EmbeddingError,
|
|
31
|
+
IndexError,
|
|
32
|
+
MicroRAGError,
|
|
33
|
+
SearchError,
|
|
34
|
+
StorageError,
|
|
35
|
+
)
|
|
36
|
+
from microrag.models import Document, SearchResult
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
# Main classes
|
|
42
|
+
"MicroRAG",
|
|
43
|
+
"RAGConfig",
|
|
44
|
+
# Data models
|
|
45
|
+
"Document",
|
|
46
|
+
"SearchResult",
|
|
47
|
+
# Exceptions
|
|
48
|
+
"MicroRAGError",
|
|
49
|
+
"ConfigurationError",
|
|
50
|
+
"DocumentError",
|
|
51
|
+
"EmbeddingError",
|
|
52
|
+
"IndexError",
|
|
53
|
+
"SearchError",
|
|
54
|
+
"StorageError",
|
|
55
|
+
# Version
|
|
56
|
+
"__version__",
|
|
57
|
+
]
|
microrag/config.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Configuration for MicroRAG."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from microrag.stopwords import ENGLISH_STOPWORDS
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class RAGConfig:
|
|
11
|
+
"""Configuration for MicroRAG instance.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
model_path: Path to sentence-transformer model directory.
|
|
15
|
+
model_file: ONNX model filename within model_path (for quantized models).
|
|
16
|
+
db_path: DuckDB database path. Use ":memory:" for in-memory database.
|
|
17
|
+
embedding_dim: Dimension of embedding vectors.
|
|
18
|
+
chunk_size: Maximum size of text chunks in characters.
|
|
19
|
+
chunk_overlap: Overlap between consecutive chunks.
|
|
20
|
+
hybrid_enabled: Enable hybrid search (semantic + BM25 + FTS).
|
|
21
|
+
hybrid_alpha: Weight for semantic search in fusion (0-1).
|
|
22
|
+
similarity_threshold: Minimum score threshold for results.
|
|
23
|
+
abbreviations: Mapping of abbreviations to expansions for query processing.
|
|
24
|
+
stopwords: Set of stopwords for BM25 tokenization.
|
|
25
|
+
remove_stopwords: Enable stopword removal in BM25 tokenization.
|
|
26
|
+
hnsw_ef_construction: HNSW index build-time parameter.
|
|
27
|
+
hnsw_ef_search: HNSW index search-time parameter.
|
|
28
|
+
hnsw_m: HNSW index M parameter (connections per layer).
|
|
29
|
+
batch_size: Batch size for embedding generation.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
model_path: str
|
|
33
|
+
model_file: str | None = None
|
|
34
|
+
db_path: str = ":memory:"
|
|
35
|
+
embedding_dim: int = 384
|
|
36
|
+
chunk_size: int = 1000
|
|
37
|
+
chunk_overlap: int = 200
|
|
38
|
+
hybrid_enabled: bool = True
|
|
39
|
+
hybrid_alpha: float = 0.7
|
|
40
|
+
similarity_threshold: float = 0.4
|
|
41
|
+
abbreviations: dict[str, str] = field(default_factory=dict)
|
|
42
|
+
stopwords: frozenset[str] = field(default_factory=lambda: ENGLISH_STOPWORDS)
|
|
43
|
+
remove_stopwords: bool = True
|
|
44
|
+
hnsw_ef_construction: int = 200
|
|
45
|
+
hnsw_ef_search: int = 100
|
|
46
|
+
hnsw_m: int = 16
|
|
47
|
+
batch_size: int = 32
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
if not self.model_path:
|
|
51
|
+
raise ValueError("model_path is required")
|
|
52
|
+
if self.embedding_dim <= 0:
|
|
53
|
+
raise ValueError("embedding_dim must be positive")
|
|
54
|
+
if self.chunk_size <= 0:
|
|
55
|
+
raise ValueError("chunk_size must be positive")
|
|
56
|
+
if self.chunk_overlap < 0:
|
|
57
|
+
raise ValueError("chunk_overlap cannot be negative")
|
|
58
|
+
if self.chunk_overlap >= self.chunk_size:
|
|
59
|
+
raise ValueError("chunk_overlap must be less than chunk_size")
|
|
60
|
+
if not 0 <= self.hybrid_alpha <= 1:
|
|
61
|
+
raise ValueError("hybrid_alpha must be between 0 and 1")
|
|
62
|
+
if not 0 <= self.similarity_threshold <= 1:
|
|
63
|
+
raise ValueError("similarity_threshold must be between 0 and 1")
|
|
64
|
+
|
|
65
|
+
def with_updates(self, **kwargs: Any) -> "RAGConfig":
|
|
66
|
+
"""Create a new config with updated values."""
|
|
67
|
+
current: dict[str, Any] = {
|
|
68
|
+
"model_path": self.model_path,
|
|
69
|
+
"model_file": self.model_file,
|
|
70
|
+
"db_path": self.db_path,
|
|
71
|
+
"embedding_dim": self.embedding_dim,
|
|
72
|
+
"chunk_size": self.chunk_size,
|
|
73
|
+
"chunk_overlap": self.chunk_overlap,
|
|
74
|
+
"hybrid_enabled": self.hybrid_enabled,
|
|
75
|
+
"hybrid_alpha": self.hybrid_alpha,
|
|
76
|
+
"similarity_threshold": self.similarity_threshold,
|
|
77
|
+
"abbreviations": dict(self.abbreviations),
|
|
78
|
+
"stopwords": self.stopwords,
|
|
79
|
+
"remove_stopwords": self.remove_stopwords,
|
|
80
|
+
"hnsw_ef_construction": self.hnsw_ef_construction,
|
|
81
|
+
"hnsw_ef_search": self.hnsw_ef_search,
|
|
82
|
+
"hnsw_m": self.hnsw_m,
|
|
83
|
+
"batch_size": self.batch_size,
|
|
84
|
+
}
|
|
85
|
+
current.update(kwargs)
|
|
86
|
+
return RAGConfig(**current)
|
microrag/core.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Core MicroRAG class."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from microrag.config import RAGConfig
|
|
8
|
+
from microrag.embedding import EmbeddingModel
|
|
9
|
+
from microrag.exceptions import DocumentError, MicroRAGError
|
|
10
|
+
from microrag.models import Document, SearchResult
|
|
11
|
+
from microrag.query_processor import QueryProcessor
|
|
12
|
+
from microrag.search.hybrid import HybridSearcher
|
|
13
|
+
from microrag.storage import DuckDBStorage
|
|
14
|
+
from microrag.utils import chunk_text, generate_id, normalize_document_input
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MicroRAG:
|
|
18
|
+
"""Main MicroRAG class for document indexing and retrieval.
|
|
19
|
+
|
|
20
|
+
MicroRAG provides a simple API for:
|
|
21
|
+
- Adding documents with automatic embedding generation
|
|
22
|
+
- Building HNSW, BM25, and FTS indexes
|
|
23
|
+
- Hybrid search with RRF fusion
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
```python
|
|
27
|
+
config = RAGConfig(
|
|
28
|
+
model_path="/path/to/model",
|
|
29
|
+
db_path="./rag.duckdb",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
with MicroRAG(config) as rag:
|
|
33
|
+
rag.add_documents(["Doc 1", "Doc 2"])
|
|
34
|
+
rag.build_index()
|
|
35
|
+
results = rag.search("query")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
config: RAGConfig instance with all settings.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, config: RAGConfig) -> None:
|
|
43
|
+
self._config = config
|
|
44
|
+
self._storage: DuckDBStorage | None = None
|
|
45
|
+
self._embedding_model: EmbeddingModel | None = None
|
|
46
|
+
self._query_processor: QueryProcessor | None = None
|
|
47
|
+
self._searcher: HybridSearcher | None = None
|
|
48
|
+
self._documents: list[Document] = []
|
|
49
|
+
self._index_built = False
|
|
50
|
+
|
|
51
|
+
def __enter__(self) -> "MicroRAG":
|
|
52
|
+
"""Enter context manager."""
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def __exit__(
|
|
56
|
+
self,
|
|
57
|
+
exc_type: type[BaseException] | None,
|
|
58
|
+
exc_val: BaseException | None,
|
|
59
|
+
exc_tb: TracebackType | None,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Exit context manager and close resources."""
|
|
62
|
+
self.close()
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def config(self) -> RAGConfig:
|
|
66
|
+
"""Get the configuration."""
|
|
67
|
+
return self._config
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def storage(self) -> DuckDBStorage:
|
|
71
|
+
"""Get or create the storage adapter."""
|
|
72
|
+
if self._storage is None:
|
|
73
|
+
self._storage = DuckDBStorage(
|
|
74
|
+
db_path=self._config.db_path,
|
|
75
|
+
embedding_dim=self._config.embedding_dim,
|
|
76
|
+
)
|
|
77
|
+
return self._storage
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def embedding_model(self) -> EmbeddingModel:
|
|
81
|
+
"""Get or create the embedding model."""
|
|
82
|
+
if self._embedding_model is None:
|
|
83
|
+
self._embedding_model = EmbeddingModel(
|
|
84
|
+
model_path=self._config.model_path,
|
|
85
|
+
model_file=self._config.model_file,
|
|
86
|
+
batch_size=self._config.batch_size,
|
|
87
|
+
)
|
|
88
|
+
return self._embedding_model
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def query_processor(self) -> QueryProcessor:
|
|
92
|
+
"""Get or create the query processor."""
|
|
93
|
+
if self._query_processor is None:
|
|
94
|
+
self._query_processor = QueryProcessor(
|
|
95
|
+
abbreviations=dict(self._config.abbreviations),
|
|
96
|
+
stopwords=self._config.stopwords,
|
|
97
|
+
remove_stopwords=self._config.remove_stopwords,
|
|
98
|
+
)
|
|
99
|
+
return self._query_processor
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def searcher(self) -> HybridSearcher:
|
|
103
|
+
"""Get or create the hybrid searcher."""
|
|
104
|
+
if self._searcher is None:
|
|
105
|
+
self._searcher = HybridSearcher(
|
|
106
|
+
storage=self.storage,
|
|
107
|
+
embedding_model=self.embedding_model,
|
|
108
|
+
query_processor=self.query_processor,
|
|
109
|
+
alpha=self._config.hybrid_alpha,
|
|
110
|
+
)
|
|
111
|
+
return self._searcher
|
|
112
|
+
|
|
113
|
+
def add_documents(
|
|
114
|
+
self,
|
|
115
|
+
documents: Sequence[str | dict[str, Any] | Document],
|
|
116
|
+
chunk: bool = True,
|
|
117
|
+
) -> list[str]:
|
|
118
|
+
"""Add documents to the RAG system.
|
|
119
|
+
|
|
120
|
+
Documents can be provided in various formats:
|
|
121
|
+
- str: Plain text content
|
|
122
|
+
- dict: {"content": str, "metadata": dict, "id": str}
|
|
123
|
+
- Document: Document dataclass
|
|
124
|
+
|
|
125
|
+
Large documents are automatically chunked if chunk=True.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
documents: Sequence of documents in any supported format.
|
|
129
|
+
chunk: Whether to chunk large documents.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
List of document IDs that were added.
|
|
133
|
+
"""
|
|
134
|
+
doc_ids = []
|
|
135
|
+
|
|
136
|
+
for doc in documents:
|
|
137
|
+
try:
|
|
138
|
+
doc_id, content, metadata = normalize_document_input(doc)
|
|
139
|
+
except ValueError as e:
|
|
140
|
+
raise DocumentError(str(e)) from e
|
|
141
|
+
|
|
142
|
+
# Chunk if needed
|
|
143
|
+
if chunk and len(content) > self._config.chunk_size:
|
|
144
|
+
chunks = chunk_text(
|
|
145
|
+
content,
|
|
146
|
+
chunk_size=self._config.chunk_size,
|
|
147
|
+
chunk_overlap=self._config.chunk_overlap,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
for i, chunk_content in enumerate(chunks):
|
|
151
|
+
chunk_id = doc_id or generate_id(chunk_content)
|
|
152
|
+
if len(chunks) > 1:
|
|
153
|
+
chunk_id = f"{chunk_id}_{i}"
|
|
154
|
+
|
|
155
|
+
chunk_metadata = {**metadata, "_chunk_index": i, "_total_chunks": len(chunks)}
|
|
156
|
+
|
|
157
|
+
doc_obj = Document(
|
|
158
|
+
id=chunk_id,
|
|
159
|
+
content=chunk_content,
|
|
160
|
+
metadata=chunk_metadata,
|
|
161
|
+
)
|
|
162
|
+
self._documents.append(doc_obj)
|
|
163
|
+
doc_ids.append(chunk_id)
|
|
164
|
+
else:
|
|
165
|
+
final_id = doc_id or generate_id(content)
|
|
166
|
+
doc_obj = Document(
|
|
167
|
+
id=final_id,
|
|
168
|
+
content=content,
|
|
169
|
+
metadata=metadata,
|
|
170
|
+
)
|
|
171
|
+
self._documents.append(doc_obj)
|
|
172
|
+
doc_ids.append(final_id)
|
|
173
|
+
|
|
174
|
+
self._index_built = False
|
|
175
|
+
return doc_ids
|
|
176
|
+
|
|
177
|
+
def build_index(self) -> None:
|
|
178
|
+
"""Build all indexes (HNSW, BM25, FTS).
|
|
179
|
+
|
|
180
|
+
This method:
|
|
181
|
+
1. Generates embeddings for all documents
|
|
182
|
+
2. Stores documents in DuckDB
|
|
183
|
+
3. Builds HNSW vector index
|
|
184
|
+
4. Builds BM25 index
|
|
185
|
+
5. Builds FTS index
|
|
186
|
+
|
|
187
|
+
Must be called after add_documents() and before search().
|
|
188
|
+
"""
|
|
189
|
+
if not self._documents:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
# Generate embeddings for documents without them
|
|
193
|
+
docs_needing_embeddings = [d for d in self._documents if d.embedding is None]
|
|
194
|
+
|
|
195
|
+
if docs_needing_embeddings:
|
|
196
|
+
contents = [d.content for d in docs_needing_embeddings]
|
|
197
|
+
embeddings = self.embedding_model.encode(contents)
|
|
198
|
+
|
|
199
|
+
for doc, embedding in zip(docs_needing_embeddings, embeddings, strict=True):
|
|
200
|
+
doc.embedding = embedding
|
|
201
|
+
|
|
202
|
+
# Store documents
|
|
203
|
+
self.storage.add_documents(self._documents)
|
|
204
|
+
|
|
205
|
+
# Build HNSW index
|
|
206
|
+
self.storage.build_vector_index(
|
|
207
|
+
ef_construction=self._config.hnsw_ef_construction,
|
|
208
|
+
ef_search=self._config.hnsw_ef_search,
|
|
209
|
+
m=self._config.hnsw_m,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Build FTS index
|
|
213
|
+
self.storage.build_fts_index()
|
|
214
|
+
|
|
215
|
+
# Build BM25 index
|
|
216
|
+
self.searcher.build_index(self._documents)
|
|
217
|
+
|
|
218
|
+
self._index_built = True
|
|
219
|
+
|
|
220
|
+
def search(
|
|
221
|
+
self,
|
|
222
|
+
query: str,
|
|
223
|
+
top_k: int = 10,
|
|
224
|
+
hybrid: bool | None = None,
|
|
225
|
+
threshold: float | None = None,
|
|
226
|
+
) -> list[SearchResult]:
|
|
227
|
+
"""Search for documents matching the query.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
query: Search query string.
|
|
231
|
+
top_k: Maximum number of results to return.
|
|
232
|
+
hybrid: Override hybrid search setting from config.
|
|
233
|
+
threshold: Override similarity threshold from config.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
List of SearchResult objects sorted by relevance.
|
|
237
|
+
"""
|
|
238
|
+
if not self._index_built:
|
|
239
|
+
raise MicroRAGError("Index not built. Call build_index() first.")
|
|
240
|
+
|
|
241
|
+
hybrid_enabled = hybrid if hybrid is not None else self._config.hybrid_enabled
|
|
242
|
+
similarity_threshold = (
|
|
243
|
+
threshold if threshold is not None else self._config.similarity_threshold
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return self.searcher.search(
|
|
247
|
+
query=query,
|
|
248
|
+
top_k=top_k,
|
|
249
|
+
hybrid_enabled=hybrid_enabled,
|
|
250
|
+
similarity_threshold=similarity_threshold,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def get_document(self, doc_id: str) -> Document | None:
|
|
254
|
+
"""Retrieve a document by ID.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
doc_id: Document ID.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Document if found, None otherwise.
|
|
261
|
+
"""
|
|
262
|
+
return self.storage.get_document(doc_id)
|
|
263
|
+
|
|
264
|
+
def get_all_documents(self) -> list[Document]:
|
|
265
|
+
"""Retrieve all documents from storage.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
List of all documents.
|
|
269
|
+
"""
|
|
270
|
+
return self.storage.get_all_documents()
|
|
271
|
+
|
|
272
|
+
def count(self) -> int:
|
|
273
|
+
"""Get the number of documents in storage.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Document count.
|
|
277
|
+
"""
|
|
278
|
+
return self.storage.count()
|
|
279
|
+
|
|
280
|
+
def clear(self) -> None:
|
|
281
|
+
"""Remove all documents and reset indexes."""
|
|
282
|
+
self.storage.clear()
|
|
283
|
+
self._documents.clear()
|
|
284
|
+
self._index_built = False
|
|
285
|
+
|
|
286
|
+
def close(self) -> None:
|
|
287
|
+
"""Close all resources."""
|
|
288
|
+
if self._storage is not None:
|
|
289
|
+
self._storage.close()
|
|
290
|
+
self._storage = None
|
|
291
|
+
self._embedding_model = None
|
|
292
|
+
self._query_processor = None
|
|
293
|
+
self._searcher = None
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""ONNX-backed embedding model using sentence-transformers."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from numpy.typing import NDArray
|
|
8
|
+
from sentence_transformers import SentenceTransformer
|
|
9
|
+
|
|
10
|
+
from microrag.exceptions import EmbeddingError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EmbeddingModel:
|
|
14
|
+
"""Embedding model using sentence-transformers with ONNX backend.
|
|
15
|
+
|
|
16
|
+
This class wraps sentence-transformers to provide ONNX-optimized
|
|
17
|
+
embeddings for CPU inference.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
model_path: Path to the sentence-transformer model directory.
|
|
21
|
+
model_file: Optional ONNX model filename for quantized models.
|
|
22
|
+
batch_size: Batch size for encoding.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
model_path: str,
|
|
28
|
+
model_file: str | None = None,
|
|
29
|
+
batch_size: int = 32,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._model_path = Path(model_path)
|
|
32
|
+
self._model_file = model_file
|
|
33
|
+
self._batch_size = batch_size
|
|
34
|
+
self._model: SentenceTransformer | None = None
|
|
35
|
+
|
|
36
|
+
def _load_model(self) -> SentenceTransformer:
|
|
37
|
+
"""Load the sentence-transformer model with ONNX backend."""
|
|
38
|
+
if self._model is not None:
|
|
39
|
+
return self._model
|
|
40
|
+
|
|
41
|
+
model_path = str(self._model_path)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
# Load with ONNX backend
|
|
45
|
+
model_kwargs: dict[str, dict[str, str]] = {}
|
|
46
|
+
|
|
47
|
+
if self._model_file:
|
|
48
|
+
model_kwargs["model_kwargs"] = {"file_name": self._model_file}
|
|
49
|
+
|
|
50
|
+
self._model = SentenceTransformer(
|
|
51
|
+
model_path,
|
|
52
|
+
backend="onnx",
|
|
53
|
+
**model_kwargs,
|
|
54
|
+
)
|
|
55
|
+
return self._model
|
|
56
|
+
except Exception as e:
|
|
57
|
+
raise EmbeddingError(f"Failed to load model from {model_path}: {e}") from e
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def model(self) -> SentenceTransformer:
|
|
61
|
+
"""Get the loaded model, loading it if necessary."""
|
|
62
|
+
return self._load_model()
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def embedding_dim(self) -> int:
|
|
66
|
+
"""Get the embedding dimension."""
|
|
67
|
+
return self.model.get_sentence_embedding_dimension() or 384
|
|
68
|
+
|
|
69
|
+
def encode(
|
|
70
|
+
self,
|
|
71
|
+
texts: str | Sequence[str],
|
|
72
|
+
normalize: bool = True,
|
|
73
|
+
) -> NDArray[np.float32]:
|
|
74
|
+
"""Encode texts into embeddings.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
texts: Single text or sequence of texts to encode.
|
|
78
|
+
normalize: Whether to L2-normalize embeddings.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Array of shape (n_texts, embedding_dim) with float32 embeddings.
|
|
82
|
+
"""
|
|
83
|
+
if isinstance(texts, str):
|
|
84
|
+
texts = [texts]
|
|
85
|
+
|
|
86
|
+
if not texts:
|
|
87
|
+
return np.array([], dtype=np.float32).reshape(0, self.embedding_dim)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
embeddings = self.model.encode(
|
|
91
|
+
list(texts),
|
|
92
|
+
batch_size=self._batch_size,
|
|
93
|
+
normalize_embeddings=normalize,
|
|
94
|
+
convert_to_numpy=True,
|
|
95
|
+
show_progress_bar=False,
|
|
96
|
+
)
|
|
97
|
+
result: NDArray[np.float32] = np.asarray(embeddings, dtype=np.float32)
|
|
98
|
+
return result
|
|
99
|
+
except Exception as e:
|
|
100
|
+
raise EmbeddingError(f"Failed to encode texts: {e}") from e
|
|
101
|
+
|
|
102
|
+
def encode_single(
|
|
103
|
+
self,
|
|
104
|
+
text: str,
|
|
105
|
+
normalize: bool = True,
|
|
106
|
+
) -> NDArray[np.float32]:
|
|
107
|
+
"""Encode a single text into an embedding.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
text: Text to encode.
|
|
111
|
+
normalize: Whether to L2-normalize the embedding.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Array of shape (embedding_dim,) with float32 embedding.
|
|
115
|
+
"""
|
|
116
|
+
embeddings = self.encode([text], normalize=normalize)
|
|
117
|
+
result: NDArray[np.float32] = embeddings[0]
|
|
118
|
+
return result
|
microrag/exceptions.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""MicroRAG exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MicroRAGError(Exception):
|
|
5
|
+
"""Base exception for MicroRAG errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(MicroRAGError):
|
|
9
|
+
"""Raised when configuration is invalid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EmbeddingError(MicroRAGError):
|
|
13
|
+
"""Raised when embedding generation fails."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StorageError(MicroRAGError):
|
|
17
|
+
"""Raised when storage operations fail."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IndexError(MicroRAGError):
|
|
21
|
+
"""Raised when index operations fail."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SearchError(MicroRAGError):
|
|
25
|
+
"""Raised when search operations fail."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DocumentError(MicroRAGError):
|
|
29
|
+
"""Raised when document operations fail."""
|
microrag/models.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Data models for MicroRAG."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from numpy.typing import NDArray
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Document:
|
|
12
|
+
"""A document with content, metadata, and optional embedding."""
|
|
13
|
+
|
|
14
|
+
id: str
|
|
15
|
+
content: str
|
|
16
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
17
|
+
embedding: NDArray[np.float32] | None = None
|
|
18
|
+
|
|
19
|
+
def __post_init__(self) -> None:
|
|
20
|
+
if not self.content:
|
|
21
|
+
raise ValueError("Document content cannot be empty")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SearchResult:
|
|
26
|
+
"""A search result containing document, score, and rank."""
|
|
27
|
+
|
|
28
|
+
document: Document
|
|
29
|
+
score: float
|
|
30
|
+
rank: int
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def id(self) -> str:
|
|
34
|
+
"""Convenience accessor for document ID."""
|
|
35
|
+
return self.document.id
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def content(self) -> str:
|
|
39
|
+
"""Convenience accessor for document content."""
|
|
40
|
+
return self.document.content
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def metadata(self) -> dict[str, Any]:
|
|
44
|
+
"""Convenience accessor for document metadata."""
|
|
45
|
+
return self.document.metadata
|