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 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,5 @@
1
+ """Embedding module for MicroRAG."""
2
+
3
+ from microrag.embedding.model import EmbeddingModel
4
+
5
+ __all__ = ["EmbeddingModel"]
@@ -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