haiku.rag 0.3.3__py3-none-any.whl → 0.4.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.

Potentially problematic release.


This version of haiku.rag might be problematic. Click here for more details.

haiku/rag/app.py CHANGED
@@ -74,7 +74,7 @@ class HaikuRAGApp:
74
74
  self.console.print(f"[red]Error: {e}[/red]")
75
75
 
76
76
  async def rebuild(self):
77
- async with HaikuRAG(db_path=self.db_path) as client:
77
+ async with HaikuRAG(db_path=self.db_path, skip_validation=True) as client:
78
78
  try:
79
79
  documents = await client.list_documents()
80
80
  total_docs = len(documents)
haiku/rag/chunker.py CHANGED
@@ -6,15 +6,11 @@ from haiku.rag.config import Config
6
6
 
7
7
 
8
8
  class Chunker:
9
- """
10
- A class that chunks text into smaller pieces for embedding and retrieval.
11
-
12
- Parameters
13
- ----------
14
- chunk_size : int
15
- The maximum size of a chunk in characters.
16
- chunk_overlap : int
17
- The number of characters of overlap between chunks.
9
+ """A class that chunks text into smaller pieces for embedding and retrieval.
10
+
11
+ Args:
12
+ chunk_size: The maximum size of a chunk in tokens.
13
+ chunk_overlap: The number of tokens of overlap between chunks.
18
14
  """
19
15
 
20
16
  encoder: ClassVar[tiktoken.Encoding] = tiktoken.encoding_for_model("gpt-4o")
@@ -28,18 +24,13 @@ class Chunker:
28
24
  self.chunk_overlap = chunk_overlap
29
25
 
30
26
  async def chunk(self, text: str) -> list[str]:
31
- """
32
- Split the text into chunks.
27
+ """Split the text into chunks based on token boundaries.
33
28
 
34
- Parameters
35
- ----------
36
- text : str
37
- The text to be split into chunks.
29
+ Args:
30
+ text: The text to be split into chunks.
38
31
 
39
- Returns
40
- -------
41
- list
42
- A list of text chunks.
32
+ Returns:
33
+ A list of text chunks with token-based boundaries and overlap.
43
34
  """
44
35
  if not text:
45
36
  return []
haiku/rag/cli.py CHANGED
@@ -5,7 +5,7 @@ import typer
5
5
  from rich.console import Console
6
6
 
7
7
  from haiku.rag.app import HaikuRAGApp
8
- from haiku.rag.utils import get_default_data_dir
8
+ from haiku.rag.utils import get_default_data_dir, is_up_to_date
9
9
 
10
10
  cli = typer.Typer(
11
11
  context_settings={"help_option_names": ["-h", "--help"]}, no_args_is_help=True
@@ -15,6 +15,23 @@ console = Console()
15
15
  event_loop = asyncio.get_event_loop()
16
16
 
17
17
 
18
+ async def check_version():
19
+ """Check if haiku.rag is up to date and show warning if not."""
20
+ up_to_date, current_version, latest_version = await is_up_to_date()
21
+ if not up_to_date:
22
+ console.print(
23
+ f"[yellow]Warning: haiku.rag is outdated. Current: {current_version}, Latest: {latest_version}[/yellow]"
24
+ )
25
+ console.print("[yellow]Please update.[/yellow]")
26
+
27
+
28
+ @cli.callback()
29
+ def main():
30
+ """haiku.rag CLI - SQLite-based RAG system"""
31
+ # Run version check before any command
32
+ event_loop.run_until_complete(check_version())
33
+
34
+
18
35
  @cli.command("list", help="List all stored documents")
19
36
  def list_documents(
20
37
  db: Path = typer.Option(
haiku/rag/client.py CHANGED
@@ -10,6 +10,7 @@ import httpx
10
10
 
11
11
  from haiku.rag.config import Config
12
12
  from haiku.rag.reader import FileReader
13
+ from haiku.rag.reranking import get_reranker
13
14
  from haiku.rag.store.engine import Store
14
15
  from haiku.rag.store.models.chunk import Chunk
15
16
  from haiku.rag.store.models.document import Document
@@ -24,12 +25,18 @@ class HaikuRAG:
24
25
  self,
25
26
  db_path: Path | Literal[":memory:"] = Config.DEFAULT_DATA_DIR
26
27
  / "haiku.rag.sqlite",
28
+ skip_validation: bool = False,
27
29
  ):
28
- """Initialize the RAG client with a database path."""
30
+ """Initialize the RAG client with a database path.
31
+
32
+ Args:
33
+ db_path: Path to the SQLite database file or ":memory:" for in-memory database.
34
+ skip_validation: Whether to skip configuration validation on database load.
35
+ """
29
36
  if isinstance(db_path, Path):
30
37
  if not db_path.parent.exists():
31
38
  Path.mkdir(db_path.parent, parents=True)
32
- self.store = Store(db_path)
39
+ self.store = Store(db_path, skip_validation=skip_validation)
33
40
  self.document_repository = DocumentRepository(self.store)
34
41
  self.chunk_repository = ChunkRepository(self.store)
35
42
 
@@ -45,7 +52,16 @@ class HaikuRAG:
45
52
  async def create_document(
46
53
  self, content: str, uri: str | None = None, metadata: dict | None = None
47
54
  ) -> Document:
48
- """Create a new document with optional URI and metadata."""
55
+ """Create a new document with optional URI and metadata.
56
+
57
+ Args:
58
+ content: The text content of the document.
59
+ uri: Optional URI identifier for the document.
60
+ metadata: Optional metadata dictionary.
61
+
62
+ Returns:
63
+ The created Document instance.
64
+ """
49
65
  document = Document(
50
66
  content=content,
51
67
  uri=uri,
@@ -165,29 +181,26 @@ class HaikuRAG:
165
181
 
166
182
  # Create a temporary file with the appropriate extension
167
183
  with tempfile.NamedTemporaryFile(
168
- mode="wb", suffix=file_extension, delete=False
184
+ mode="wb", suffix=file_extension
169
185
  ) as temp_file:
170
186
  temp_file.write(response.content)
187
+ temp_file.flush() # Ensure content is written to disk
171
188
  temp_path = Path(temp_file.name)
172
189
 
173
- try:
174
190
  # Parse the content using FileReader
175
191
  content = FileReader.parse_file(temp_path)
176
192
 
177
- # Merge metadata with contentType and md5
178
- metadata.update({"contentType": content_type, "md5": md5_hash})
179
-
180
- if existing_doc:
181
- existing_doc.content = content
182
- existing_doc.metadata = metadata
183
- return await self.update_document(existing_doc)
184
- else:
185
- return await self.create_document(
186
- content=content, uri=url, metadata=metadata
187
- )
188
- finally:
189
- # Clean up temporary file
190
- temp_path.unlink(missing_ok=True)
193
+ # Merge metadata with contentType and md5
194
+ metadata.update({"contentType": content_type, "md5": md5_hash})
195
+
196
+ if existing_doc:
197
+ existing_doc.content = content
198
+ existing_doc.metadata = metadata
199
+ return await self.update_document(existing_doc)
200
+ else:
201
+ return await self.create_document(
202
+ content=content, uri=url, metadata=metadata
203
+ )
191
204
 
192
205
  def _get_extension_from_content_type_or_url(
193
206
  self, url: str, content_type: str
@@ -221,11 +234,25 @@ class HaikuRAG:
221
234
  return ".html"
222
235
 
223
236
  async def get_document_by_id(self, document_id: int) -> Document | None:
224
- """Get a document by its ID."""
237
+ """Get a document by its ID.
238
+
239
+ Args:
240
+ document_id: The unique identifier of the document.
241
+
242
+ Returns:
243
+ The Document instance if found, None otherwise.
244
+ """
225
245
  return await self.document_repository.get_by_id(document_id)
226
246
 
227
247
  async def get_document_by_uri(self, uri: str) -> Document | None:
228
- """Get a document by its URI."""
248
+ """Get a document by its URI.
249
+
250
+ Args:
251
+ uri: The URI identifier of the document.
252
+
253
+ Returns:
254
+ The Document instance if found, None otherwise.
255
+ """
229
256
  return await self.document_repository.get_by_uri(uri)
230
257
 
231
258
  async def update_document(self, document: Document) -> Document:
@@ -239,32 +266,55 @@ class HaikuRAG:
239
266
  async def list_documents(
240
267
  self, limit: int | None = None, offset: int | None = None
241
268
  ) -> list[Document]:
242
- """List all documents with optional pagination."""
269
+ """List all documents with optional pagination.
270
+
271
+ Args:
272
+ limit: Maximum number of documents to return.
273
+ offset: Number of documents to skip.
274
+
275
+ Returns:
276
+ List of Document instances.
277
+ """
243
278
  return await self.document_repository.list_all(limit=limit, offset=offset)
244
279
 
245
280
  async def search(
246
- self, query: str, limit: int = 5, k: int = 60
281
+ self, query: str, limit: int = 3, k: int = 60, rerank=Config.RERANK
247
282
  ) -> list[tuple[Chunk, float]]:
248
- """Search for relevant chunks using hybrid search (vector similarity + full-text search).
283
+ """Search for relevant chunks using hybrid search (vector similarity + full-text search) with reranking.
249
284
 
250
285
  Args:
251
- query: The search query string
252
- limit: Maximum number of results to return
253
- k: Parameter for Reciprocal Rank Fusion (default: 60)
286
+ query: The search query string.
287
+ limit: Maximum number of results to return.
288
+ k: Parameter for Reciprocal Rank Fusion (default: 60).
254
289
 
255
290
  Returns:
256
- List of (chunk, score) tuples ordered by relevance
291
+ List of (chunk, score) tuples ordered by relevance.
257
292
  """
258
- return await self.chunk_repository.search_chunks_hybrid(query, limit, k)
293
+
294
+ if not rerank:
295
+ return await self.chunk_repository.search_chunks_hybrid(query, limit, k)
296
+
297
+ # Get more initial results (3X) for reranking
298
+ search_results = await self.chunk_repository.search_chunks_hybrid(
299
+ query, limit * 3, k
300
+ )
301
+
302
+ # Apply reranking
303
+ reranker = get_reranker()
304
+ chunks = [chunk for chunk, _ in search_results]
305
+ reranked_results = await reranker.rerank(query, chunks, top_n=limit)
306
+
307
+ # Return reranked results with scores from reranker
308
+ return reranked_results
259
309
 
260
310
  async def ask(self, question: str) -> str:
261
311
  """Ask a question using the configured QA agent.
262
312
 
263
313
  Args:
264
- question: The question to ask
314
+ question: The question to ask.
265
315
 
266
316
  Returns:
267
- The generated answer as a string
317
+ The generated answer as a string.
268
318
  """
269
319
  from haiku.rag.qa import get_qa_agent
270
320
 
@@ -277,12 +327,16 @@ class HaikuRAG:
277
327
  Yields:
278
328
  int: The ID of the document currently being processed
279
329
  """
280
- documents = await self.list_documents()
330
+ await self.chunk_repository.delete_all()
331
+ self.store.recreate_embeddings_table()
281
332
 
282
- if not documents:
283
- return
333
+ # Update settings to current config
334
+ from haiku.rag.store.repositories.settings import SettingsRepository
284
335
 
285
- await self.chunk_repository.delete_all()
336
+ settings_repo = SettingsRepository(self.store)
337
+ settings_repo.save()
338
+
339
+ documents = await self.list_documents()
286
340
 
287
341
  for doc in documents:
288
342
  if doc.id is not None:
haiku/rag/config.py CHANGED
@@ -19,6 +19,10 @@ class AppConfig(BaseModel):
19
19
  EMBEDDINGS_MODEL: str = "mxbai-embed-large"
20
20
  EMBEDDINGS_VECTOR_DIM: int = 1024
21
21
 
22
+ RERANK: bool = True
23
+ RERANK_PROVIDER: str = "mxbai"
24
+ RERANK_MODEL: str = "mixedbread-ai/mxbai-rerank-base-v2"
25
+
22
26
  QA_PROVIDER: str = "ollama"
23
27
  QA_MODEL: str = "qwen3"
24
28
 
@@ -31,6 +35,7 @@ class AppConfig(BaseModel):
31
35
  VOYAGE_API_KEY: str = ""
32
36
  OPENAI_API_KEY: str = ""
33
37
  ANTHROPIC_API_KEY: str = ""
38
+ COHERE_API_KEY: str = ""
34
39
 
35
40
  @field_validator("MONITOR_DIRECTORIES", mode="before")
36
41
  @classmethod
@@ -52,3 +57,5 @@ if Config.VOYAGE_API_KEY:
52
57
  os.environ["VOYAGE_API_KEY"] = Config.VOYAGE_API_KEY
53
58
  if Config.ANTHROPIC_API_KEY:
54
59
  os.environ["ANTHROPIC_API_KEY"] = Config.ANTHROPIC_API_KEY
60
+ if Config.COHERE_API_KEY:
61
+ os.environ["CO_API_KEY"] = Config.COHERE_API_KEY
@@ -1,6 +1,9 @@
1
+ from haiku.rag.config import Config
2
+
3
+
1
4
  class EmbedderBase:
2
- _model: str = ""
3
- _vector_dim: int = 0
5
+ _model: str = Config.EMBEDDINGS_MODEL
6
+ _vector_dim: int = Config.EMBEDDINGS_VECTOR_DIM
4
7
 
5
8
  def __init__(self, model: str, vector_dim: int):
6
9
  self._model = model
@@ -5,9 +5,6 @@ from haiku.rag.embeddings.base import EmbedderBase
5
5
 
6
6
 
7
7
  class Embedder(EmbedderBase):
8
- _model: str = Config.EMBEDDINGS_MODEL
9
- _vector_dim: int = 1024
10
-
11
8
  async def embed(self, text: str) -> list[float]:
12
9
  client = AsyncClient(host=Config.OLLAMA_BASE_URL)
13
10
  res = await client.embeddings(model=self._model, prompt=text)
@@ -1,13 +1,9 @@
1
1
  try:
2
2
  from openai import AsyncOpenAI
3
3
 
4
- from haiku.rag.config import Config
5
4
  from haiku.rag.embeddings.base import EmbedderBase
6
5
 
7
6
  class Embedder(EmbedderBase):
8
- _model: str = Config.EMBEDDINGS_MODEL
9
- _vector_dim: int = 1536
10
-
11
7
  async def embed(self, text: str) -> list[float]:
12
8
  client = AsyncOpenAI()
13
9
  response = await client.embeddings.create(
@@ -1,13 +1,9 @@
1
1
  try:
2
2
  from voyageai.client import Client # type: ignore
3
3
 
4
- from haiku.rag.config import Config
5
4
  from haiku.rag.embeddings.base import EmbedderBase
6
5
 
7
6
  class Embedder(EmbedderBase):
8
- _model: str = Config.EMBEDDINGS_MODEL
9
- _vector_dim: int = 1024
10
-
11
7
  async def embed(self, text: str) -> list[float]:
12
8
  client = Client()
13
9
  res = client.embed([text], model=self._model, output_dtype="float")
haiku/rag/qa/prompts.py CHANGED
@@ -6,7 +6,7 @@ Your process:
6
6
  2. Search with specific keywords and phrases from the user's question
7
7
  3. Review the search results and their relevance scores
8
8
  4. If you need additional context, perform follow-up searches with different keywords
9
- 5. Provide a comprehensive answer based only on the retrieved documents
9
+ 5. Provide a short and to the point comprehensive answer based only on the retrieved documents
10
10
 
11
11
  Guidelines:
12
12
  - Base your answers strictly on the provided document content
@@ -15,6 +15,7 @@ Guidelines:
15
15
  - Indicate when information is incomplete or when you need to search for additional context
16
16
  - If the retrieved documents don't contain sufficient information, clearly state: "I cannot find enough information in the knowledge base to answer this question."
17
17
  - For complex questions, consider breaking them down and performing multiple searches
18
+ - Stick to the answer, do not ellaborate or provde context unless asked for it.
18
19
 
19
20
  Be concise, and always maintain accuracy over completeness. Prefer short, direct answers that are well-supported by the documents.
20
21
  """
@@ -0,0 +1,37 @@
1
+ from haiku.rag.config import Config
2
+ from haiku.rag.reranking.base import RerankerBase
3
+
4
+ try:
5
+ from haiku.rag.reranking.cohere import CohereReranker
6
+ except ImportError:
7
+ pass
8
+
9
+ _reranker: RerankerBase | None = None
10
+
11
+
12
+ def get_reranker() -> RerankerBase:
13
+ """
14
+ Factory function to get the appropriate reranker based on the configuration.
15
+ """
16
+ global _reranker
17
+ if _reranker is not None:
18
+ return _reranker
19
+ if Config.RERANK_PROVIDER == "mxbai":
20
+ from haiku.rag.reranking.mxbai import MxBAIReranker
21
+
22
+ _reranker = MxBAIReranker()
23
+ return _reranker
24
+
25
+ if Config.RERANK_PROVIDER == "cohere":
26
+ try:
27
+ from haiku.rag.reranking.cohere import CohereReranker
28
+ except ImportError:
29
+ raise ImportError(
30
+ "Cohere reranker requires the 'cohere' package. "
31
+ "Please install haiku.rag with the 'cohere' extra:"
32
+ "uv pip install haiku.rag --extra cohere"
33
+ )
34
+ _reranker = CohereReranker()
35
+ return _reranker
36
+
37
+ raise ValueError(f"Unsupported reranker provider: {Config.RERANK_PROVIDER}")
@@ -0,0 +1,13 @@
1
+ from haiku.rag.config import Config
2
+ from haiku.rag.store.models.chunk import Chunk
3
+
4
+
5
+ class RerankerBase:
6
+ _model: str = Config.RERANK_MODEL
7
+
8
+ async def rerank(
9
+ self, query: str, chunks: list[Chunk], top_n: int = 10
10
+ ) -> list[tuple[Chunk, float]]:
11
+ raise NotImplementedError(
12
+ "Reranker is an abstract class. Please implement the rerank method in a subclass."
13
+ )
@@ -0,0 +1,34 @@
1
+ from haiku.rag.config import Config
2
+ from haiku.rag.reranking.base import RerankerBase
3
+ from haiku.rag.store.models.chunk import Chunk
4
+
5
+ try:
6
+ import cohere
7
+ except ImportError as e:
8
+ raise ImportError(
9
+ "cohere is not installed. Please install it with `pip install cohere` or use the cohere optional dependency."
10
+ ) from e
11
+
12
+
13
+ class CohereReranker(RerankerBase):
14
+ def __init__(self):
15
+ self._client = cohere.ClientV2(api_key=Config.COHERE_API_KEY)
16
+
17
+ async def rerank(
18
+ self, query: str, chunks: list[Chunk], top_n: int = 10
19
+ ) -> list[tuple[Chunk, float]]:
20
+ if not chunks:
21
+ return []
22
+
23
+ documents = [chunk.content for chunk in chunks]
24
+
25
+ response = self._client.rerank(
26
+ model=self._model, query=query, documents=documents, top_n=top_n
27
+ )
28
+
29
+ reranked_chunks = []
30
+ for result in response.results:
31
+ original_chunk = chunks[result.index]
32
+ reranked_chunks.append((original_chunk, result.relevance_score))
33
+
34
+ return reranked_chunks
@@ -0,0 +1,28 @@
1
+ from mxbai_rerank import MxbaiRerankV2
2
+
3
+ from haiku.rag.config import Config
4
+ from haiku.rag.reranking.base import RerankerBase
5
+ from haiku.rag.store.models.chunk import Chunk
6
+
7
+
8
+ class MxBAIReranker(RerankerBase):
9
+ def __init__(self):
10
+ self._client = MxbaiRerankV2(
11
+ Config.RERANK_MODEL, disable_transformers_warnings=True
12
+ )
13
+
14
+ async def rerank(
15
+ self, query: str, chunks: list[Chunk], top_n: int = 10
16
+ ) -> list[tuple[Chunk, float]]:
17
+ if not chunks:
18
+ return []
19
+
20
+ documents = [chunk.content for chunk in chunks]
21
+
22
+ results = self._client.rank(query=query, documents=documents, top_k=top_n)
23
+ reranked_chunks = []
24
+ for result in results:
25
+ original_chunk = chunks[result.index]
26
+ reranked_chunks.append((original_chunk, result.score))
27
+
28
+ return reranked_chunks
haiku/rag/store/engine.py CHANGED
@@ -1,23 +1,65 @@
1
1
  import sqlite3
2
2
  import struct
3
+ from importlib import metadata
3
4
  from pathlib import Path
4
5
  from typing import Literal
5
6
 
6
7
  import sqlite_vec
8
+ from packaging.version import parse
9
+ from rich.console import Console
7
10
 
11
+ from haiku.rag.config import Config
8
12
  from haiku.rag.embeddings import get_embedder
13
+ from haiku.rag.store.upgrades import upgrades
14
+ from haiku.rag.utils import int_to_semantic_version, semantic_version_to_int
9
15
 
10
16
 
11
17
  class Store:
12
- def __init__(self, db_path: Path | Literal[":memory:"]):
18
+ def __init__(
19
+ self, db_path: Path | Literal[":memory:"], skip_validation: bool = False
20
+ ):
13
21
  self.db_path: Path | Literal[":memory:"] = db_path
14
- self._connection = self.create_db()
22
+ self.create_or_update_db()
15
23
 
16
- def create_db(self) -> sqlite3.Connection:
24
+ # Validate config compatibility after connection is established
25
+ if not skip_validation:
26
+ from haiku.rag.store.repositories.settings import SettingsRepository
27
+
28
+ settings_repo = SettingsRepository(self)
29
+ settings_repo.validate_config_compatibility()
30
+ current_version = metadata.version("haiku.rag")
31
+ self.set_user_version(current_version)
32
+
33
+ def create_or_update_db(self):
17
34
  """Create the database and tables with sqlite-vec support for embeddings."""
35
+ current_version = metadata.version("haiku.rag")
36
+
18
37
  db = sqlite3.connect(self.db_path)
19
38
  db.enable_load_extension(True)
20
39
  sqlite_vec.load(db)
40
+ self._connection = db
41
+ existing_tables = [
42
+ row[0]
43
+ for row in db.execute(
44
+ "SELECT name FROM sqlite_master WHERE type='table';"
45
+ ).fetchall()
46
+ ]
47
+
48
+ # If we have a db already, perform upgrades and return
49
+ if self.db_path != ":memory:" and "documents" in existing_tables:
50
+ # Upgrade database
51
+ console = Console()
52
+ db_version = self.get_user_version()
53
+ for version, steps in upgrades:
54
+ if parse(current_version) >= parse(version) and parse(version) > parse(
55
+ db_version
56
+ ):
57
+ for step in steps:
58
+ step(db)
59
+ console.print(
60
+ f"[green][b]DB Upgrade: [/b]{step.__doc__}[/green]"
61
+ )
62
+ return
21
63
 
22
64
  # Create documents table
23
65
  db.execute("""
@@ -30,7 +72,6 @@ class Store:
30
72
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
31
73
  )
32
74
  """)
33
-
34
75
  # Create chunks table
35
76
  db.execute("""
36
77
  CREATE TABLE IF NOT EXISTS chunks (
@@ -41,7 +82,6 @@ class Store:
41
82
  FOREIGN KEY (document_id) REFERENCES documents (id) ON DELETE CASCADE
42
83
  )
43
84
  """)
44
-
45
85
  # Create vector table for chunk embeddings
46
86
  embedder = get_embedder()
47
87
  db.execute(f"""
@@ -50,7 +90,6 @@ class Store:
50
90
  embedding FLOAT[{embedder._vector_dim}]
51
91
  )
52
92
  """)
53
-
54
93
  # Create FTS5 table for full-text search
55
94
  db.execute("""
56
95
  CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
@@ -59,14 +98,61 @@ class Store:
59
98
  content_rowid='id'
60
99
  )
61
100
  """)
62
-
101
+ # Create settings table for storing current configuration
102
+ db.execute("""
103
+ CREATE TABLE IF NOT EXISTS settings (
104
+ id INTEGER PRIMARY KEY DEFAULT 1,
105
+ settings TEXT NOT NULL DEFAULT '{}'
106
+ )
107
+ """)
108
+ # Save current settings to the new database
109
+ settings_json = Config.model_dump_json()
110
+ db.execute(
111
+ "INSERT OR IGNORE INTO settings (id, settings) VALUES (1, ?)",
112
+ (settings_json,),
113
+ )
63
114
  # Create indexes for better performance
64
115
  db.execute(
65
116
  "CREATE INDEX IF NOT EXISTS idx_chunks_document_id ON chunks(document_id)"
66
117
  )
67
-
68
118
  db.commit()
69
- return db
119
+
120
+ def get_user_version(self) -> str:
121
+ """Returns the SQLite user version"""
122
+ if self._connection is None:
123
+ raise ValueError("Store connection is not available")
124
+
125
+ cursor = self._connection.execute("PRAGMA user_version;")
126
+ version = cursor.fetchone()
127
+ return int_to_semantic_version(version[0])
128
+
129
+ def set_user_version(self, version: str) -> None:
130
+ """Updates the SQLite user version"""
131
+ if self._connection is None:
132
+ raise ValueError("Store connection is not available")
133
+
134
+ self._connection.execute(
135
+ f"PRAGMA user_version = {semantic_version_to_int(version)};"
136
+ )
137
+
138
+ def recreate_embeddings_table(self) -> None:
139
+ """Recreate the embeddings table with current vector dimensions."""
140
+ if self._connection is None:
141
+ raise ValueError("Store connection is not available")
142
+
143
+ # Drop existing embeddings table
144
+ self._connection.execute("DROP TABLE IF EXISTS chunk_embeddings")
145
+
146
+ # Recreate with current dimensions
147
+ embedder = get_embedder()
148
+ self._connection.execute(f"""
149
+ CREATE VIRTUAL TABLE chunk_embeddings USING vec0(
150
+ chunk_id INTEGER PRIMARY KEY,
151
+ embedding FLOAT[{embedder._vector_dim}]
152
+ )
153
+ """)
154
+
155
+ self._connection.commit()
70
156
 
71
157
  @staticmethod
72
158
  def serialize_embedding(embedding: list[float]) -> bytes:
@@ -0,0 +1,78 @@
1
+ import json
2
+ from typing import Any
3
+
4
+ from haiku.rag.store.engine import Store
5
+
6
+
7
+ class ConfigMismatchError(Exception):
8
+ """Raised when current config doesn't match stored settings."""
9
+
10
+ pass
11
+
12
+
13
+ class SettingsRepository:
14
+ def __init__(self, store: Store):
15
+ self.store = store
16
+
17
+ def get(self) -> dict[str, Any]:
18
+ """Get all settings from the database."""
19
+ if self.store._connection is None:
20
+ raise ValueError("Store connection is not available")
21
+
22
+ cursor = self.store._connection.execute("SELECT settings FROM settings LIMIT 1")
23
+ row = cursor.fetchone()
24
+ if row:
25
+ return json.loads(row[0])
26
+ return {}
27
+
28
+ def save(self) -> None:
29
+ """Sync settings from the current AppConfig to database."""
30
+ if self.store._connection is None:
31
+ raise ValueError("Store connection is not available")
32
+
33
+ from haiku.rag.config import Config
34
+
35
+ settings_json = Config.model_dump_json()
36
+
37
+ self.store._connection.execute(
38
+ "INSERT INTO settings (id, settings) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET settings = excluded.settings",
39
+ (settings_json,),
40
+ )
41
+
42
+ self.store._connection.commit()
43
+
44
+ def validate_config_compatibility(self) -> None:
45
+ """Check if current config is compatible with stored settings.
46
+
47
+ Raises ConfigMismatchError if there are incompatible differences.
48
+ If no settings exist, saves current config.
49
+ """
50
+ db_settings = self.get()
51
+ if not db_settings:
52
+ # No settings in DB, save current config
53
+ self.save()
54
+ return
55
+
56
+ from haiku.rag.config import Config
57
+
58
+ current_config = Config.model_dump(mode="json")
59
+
60
+ # Critical settings that must match
61
+ critical_settings = [
62
+ "EMBEDDINGS_PROVIDER",
63
+ "EMBEDDINGS_MODEL",
64
+ "EMBEDDINGS_VECTOR_DIM",
65
+ "CHUNK_SIZE",
66
+ "CHUNK_OVERLAP",
67
+ ]
68
+
69
+ errors = []
70
+ for setting in critical_settings:
71
+ if db_settings.get(setting) != current_config.get(setting):
72
+ errors.append(
73
+ f"{setting}: current={current_config.get(setting)}, stored={db_settings.get(setting)}"
74
+ )
75
+
76
+ if errors:
77
+ error_msg = f"Config mismatch detected: {'; '.join(errors)}. Consider rebuilding the database with the current configuration."
78
+ raise ConfigMismatchError(error_msg)
@@ -0,0 +1,3 @@
1
+ from haiku.rag.store.upgrades.v0_3_4 import upgrades as v0_3_4_upgrades
2
+
3
+ upgrades = v0_3_4_upgrades
@@ -0,0 +1,26 @@
1
+ from collections.abc import Callable
2
+ from sqlite3 import Connection
3
+
4
+ from haiku.rag.config import Config
5
+
6
+
7
+ def add_settings_table(db: Connection) -> None:
8
+ """Create settings table for storing current configuration"""
9
+ db.execute("""
10
+ CREATE TABLE settings (
11
+ id INTEGER PRIMARY KEY DEFAULT 1,
12
+ settings TEXT NOT NULL DEFAULT '{}'
13
+ )
14
+ """)
15
+
16
+ settings_json = Config.model_dump_json()
17
+ db.execute(
18
+ "INSERT INTO settings (id, settings) VALUES (1, ?)",
19
+ (settings_json,),
20
+ )
21
+ db.commit()
22
+
23
+
24
+ upgrades: list[tuple[str, list[Callable[[Connection], None]]]] = [
25
+ ("0.3.4", [add_settings_table])
26
+ ]
haiku/rag/utils.py CHANGED
@@ -1,17 +1,20 @@
1
1
  import sys
2
+ from importlib import metadata
2
3
  from pathlib import Path
3
4
 
5
+ import httpx
6
+ from packaging.version import Version, parse
7
+
4
8
 
5
9
  def get_default_data_dir() -> Path:
6
- """
7
- Get the user data directory for the current system platform.
10
+ """Get the user data directory for the current system platform.
8
11
 
9
12
  Linux: ~/.local/share/haiku.rag
10
13
  macOS: ~/Library/Application Support/haiku.rag
11
14
  Windows: C:/Users/<USER>/AppData/Roaming/haiku.rag
12
15
 
13
- :return: User Data Path
14
- :rtype: Path
16
+ Returns:
17
+ User Data Path.
15
18
  """
16
19
  home = Path.home()
17
20
 
@@ -23,3 +26,54 @@ def get_default_data_dir() -> Path:
23
26
 
24
27
  data_path = system_paths[sys.platform]
25
28
  return data_path
29
+
30
+
31
+ def semantic_version_to_int(version: str) -> int:
32
+ """Convert a semantic version string to an integer.
33
+
34
+ Args:
35
+ version: Semantic version string.
36
+
37
+ Returns:
38
+ Integer representation of semantic version.
39
+ """
40
+ major, minor, patch = version.split(".")
41
+ major = int(major) << 16
42
+ minor = int(minor) << 8
43
+ patch = int(patch)
44
+ return major + minor + patch
45
+
46
+
47
+ def int_to_semantic_version(version: int) -> str:
48
+ """Convert an integer to a semantic version string.
49
+
50
+ Args:
51
+ version: Integer representation of semantic version.
52
+
53
+ Returns:
54
+ Semantic version string.
55
+ """
56
+ major = version >> 16
57
+ minor = (version >> 8) & 255
58
+ patch = version & 255
59
+ return f"{major}.{minor}.{patch}"
60
+
61
+
62
+ async def is_up_to_date() -> tuple[bool, Version, Version]:
63
+ """Check whether haiku.rag is current.
64
+
65
+ Returns:
66
+ A tuple containing a boolean indicating whether haiku.rag is current,
67
+ the running version and the latest version.
68
+ """
69
+
70
+ async with httpx.AsyncClient() as client:
71
+ running_version = parse(metadata.version("haiku.rag"))
72
+ try:
73
+ response = await client.get("https://pypi.org/pypi/haiku.rag/json")
74
+ data = response.json()
75
+ pypi_version = parse(data["info"]["version"])
76
+ except Exception:
77
+ # If no network connection, do not raise alarms.
78
+ pypi_version = running_version
79
+ return running_version >= pypi_version, running_version, pypi_version
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haiku.rag
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: Retrieval Augmented Generation (RAG) with SQLite
5
5
  Author-email: Yiorgis Gozadinos <ggozadinos@gmail.com>
6
6
  License: MIT
@@ -21,6 +21,7 @@ Requires-Python: >=3.10
21
21
  Requires-Dist: fastmcp>=2.8.1
22
22
  Requires-Dist: httpx>=0.28.1
23
23
  Requires-Dist: markitdown[audio-transcription,docx,pdf,pptx,xlsx]>=0.1.2
24
+ Requires-Dist: mxbai-rerank>=0.1.6
24
25
  Requires-Dist: ollama>=0.5.1
25
26
  Requires-Dist: pydantic>=2.11.7
26
27
  Requires-Dist: python-dotenv>=1.1.0
@@ -31,6 +32,8 @@ Requires-Dist: typer>=0.16.0
31
32
  Requires-Dist: watchfiles>=1.1.0
32
33
  Provides-Extra: anthropic
33
34
  Requires-Dist: anthropic>=0.56.0; extra == 'anthropic'
35
+ Provides-Extra: cohere
36
+ Requires-Dist: cohere>=5.16.1; extra == 'cohere'
34
37
  Provides-Extra: openai
35
38
  Requires-Dist: openai>=1.0.0; extra == 'openai'
36
39
  Provides-Extra: voyageai
@@ -49,6 +52,7 @@ Retrieval-Augmented Generation (RAG) library on SQLite.
49
52
  - **Multiple embedding providers**: Ollama, VoyageAI, OpenAI
50
53
  - **Multiple QA providers**: Ollama, OpenAI, Anthropic
51
54
  - **Hybrid search**: Vector + full-text search with Reciprocal Rank Fusion
55
+ - **Reranking**: Default search result reranking with MixedBread AI or Cohere
52
56
  - **Question answering**: Built-in QA agents on your documents
53
57
  - **File monitoring**: Auto-index files when run as server
54
58
  - **40+ file formats**: PDF, DOCX, HTML, Markdown, audio, URLs
@@ -88,7 +92,7 @@ async with HaikuRAG("database.db") as client:
88
92
  # Add document
89
93
  doc = await client.create_document("Your content")
90
94
 
91
- # Search
95
+ # Search (reranking enabled by default)
92
96
  results = await client.search("query")
93
97
  for chunk, score in results:
94
98
  print(f"{score:.3f}: {chunk.content}")
@@ -0,0 +1,43 @@
1
+ haiku/rag/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ haiku/rag/app.py,sha256=FpLVyP1-zAq_XPmU8CPVLkuIAeuhBOGvMqhYS8RbN40,7649
3
+ haiku/rag/chunker.py,sha256=MbCtP66OfTFoIBvqmVT9T9c87fozsYYzAQzJJEfPBVI,1812
4
+ haiku/rag/cli.py,sha256=8PC7r5odIVLyksSm_BXor2rznIZ2KDug-YhzqbFPvms,5605
5
+ haiku/rag/client.py,sha256=W8iw22v9Muoq37e3uGww2DpbQnQhQzaPZiP9MVMRKJE,12554
6
+ haiku/rag/config.py,sha256=_Ss54kmfxVAJupExLKaYjYUlFxJgb7hEEdbG4-isapY,1662
7
+ haiku/rag/logging.py,sha256=zTTGpGq5tPdcd7RpCbd9EGw1IZlQDbYkrCg9t9pqRc4,580
8
+ haiku/rag/mcp.py,sha256=tMN6fNX7ZtAER1R6DL1GkC9HZozTC4HzuQs199p7icI,4551
9
+ haiku/rag/monitor.py,sha256=r386nkhdlsU8UECwIuVwnrSlgMk3vNIuUZGNIzkZuec,2770
10
+ haiku/rag/reader.py,sha256=S7-Z72pDvSHedvgt4-RkTOwZadG88Oed9keJ69SVITk,962
11
+ haiku/rag/utils.py,sha256=Ez_tvNlRO_D8c2CBZ83Hs9Gmzcqdq4cmw_V5GBdKy_8,2214
12
+ haiku/rag/embeddings/__init__.py,sha256=4jUPe2FyIf8BGZ7AncWSlBdNXG3URejBbnkhQf3JiD0,1505
13
+ haiku/rag/embeddings/base.py,sha256=NTQvuzbZPu0LBo5wAu3qGyJ4xXUaRAt1fjBO0ygWn_Y,465
14
+ haiku/rag/embeddings/ollama.py,sha256=y6-lp0XpbnyIjoOEdtSzMdEVkU5glOwnWQ1FkpUZnpI,370
15
+ haiku/rag/embeddings/openai.py,sha256=i4Ui5hAJkcKqJkH9L3jJo7fuGYHn07td532w-ksg_T8,431
16
+ haiku/rag/embeddings/voyageai.py,sha256=0hiRTIqu-bpl-4OaCtMHvWfPdgbrzhnfZJowSV8pLRA,415
17
+ haiku/rag/qa/__init__.py,sha256=oso98Ypti7mBLTJ6Zk71YaSJ9Rgc89QXp9RSB6zSpYs,1501
18
+ haiku/rag/qa/anthropic.py,sha256=6I6cf6ySNkYbmDFdy22sA8r3GO5moiiH75tJnHcgJQA,4448
19
+ haiku/rag/qa/base.py,sha256=4ZTM_l5FAZ9cA0f8NeqRJiUAmjatwCTmSoclFw0gTFQ,1349
20
+ haiku/rag/qa/ollama.py,sha256=-UtNFErYlA_66g3WLU6lK38a1Y5zhAL6s_uZ5AP0TFs,2381
21
+ haiku/rag/qa/openai.py,sha256=dF32sGgVt8mZi5oVxByaeECs9NqLjvDiZnnpJBsrHm8,3968
22
+ haiku/rag/qa/prompts.py,sha256=W6QYvqIGcW_VmnTA88quJqCi6h6bafEP4pyrcGze9TA,1303
23
+ haiku/rag/reranking/__init__.py,sha256=6tuQCrk5CEfyGaUjB7uaskWxsB15GaV8t9rm7VVSMrg,1125
24
+ haiku/rag/reranking/base.py,sha256=LM9yUSSJ414UgBZhFTgxGprlRqzfTe4I1vgjricz2JY,405
25
+ haiku/rag/reranking/cohere.py,sha256=1iTdiaa8vvb6oHVB2qpWzUOVkyfUcimVSZp6Qr4aq4c,1049
26
+ haiku/rag/reranking/mxbai.py,sha256=46sVTsTIkzIX9THgM3u8HaEmgY7evvEyB-N54JTHvK8,867
27
+ haiku/rag/store/__init__.py,sha256=hq0W0DAC7ysqhWSP2M2uHX8cbG6kbr-sWHxhq6qQcY0,103
28
+ haiku/rag/store/engine.py,sha256=4ouAD0s-TFwEoEHjVVw_KnV6aaw5nwhe9fdT8PRXfok,6061
29
+ haiku/rag/store/models/__init__.py,sha256=s0E72zneGlowvZrFWaNxHYjOAUjgWdLxzdYsnvNRVlY,88
30
+ haiku/rag/store/models/chunk.py,sha256=lmbPOOTz-N4PXhrA5XCUxyRcSTZBo135fqkV1mwnGcE,309
31
+ haiku/rag/store/models/document.py,sha256=TVXVY-nQs-1vCORQEs9rA7zOtndeGC4dgCoujLAS054,396
32
+ haiku/rag/store/repositories/__init__.py,sha256=uIBhxjQh-4o3O-ck8b7BQ58qXQTuJdPvrDIHVhY5T1A,263
33
+ haiku/rag/store/repositories/base.py,sha256=cm3VyQXhtxvRfk1uJHpA0fDSxMpYN-mjQmRiDiLsQ68,1008
34
+ haiku/rag/store/repositories/chunk.py,sha256=gik7ZPOK3gCoG6tU1pGueAZBPmJxIb7obYFUhwINrYg,16497
35
+ haiku/rag/store/repositories/document.py,sha256=xpWOpjHFbhVwNJ1gpusEKNY6l_Qyibg9y_bdHCwcfpk,7133
36
+ haiku/rag/store/repositories/settings.py,sha256=dme3_ulQdQvyF9daavSjAd-SjZ5hh0MJoxP7iXgap-A,2492
37
+ haiku/rag/store/upgrades/__init__.py,sha256=kKS1YWT_P-CYKhKtokOLTIFNKf9jlfjFFr8lyIMeogM,100
38
+ haiku/rag/store/upgrades/v0_3_4.py,sha256=GLogKZdZ40NX1vBHKdOJju7fFzNUCHoEnjSZg17Hm2U,663
39
+ haiku_rag-0.4.0.dist-info/METADATA,sha256=59rxYHim3hZeKPkQhEmnNR_Tj6DuF9hLBj2GA2T3T_s,4235
40
+ haiku_rag-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
41
+ haiku_rag-0.4.0.dist-info/entry_points.txt,sha256=G1U3nAkNd5YDYd4v0tuYFbriz0i-JheCsFuT9kIoGCI,48
42
+ haiku_rag-0.4.0.dist-info/licenses/LICENSE,sha256=eXZrWjSk9PwYFNK9yUczl3oPl95Z4V9UXH7bPN46iPo,1065
43
+ haiku_rag-0.4.0.dist-info/RECORD,,
@@ -1,36 +0,0 @@
1
- haiku/rag/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- haiku/rag/app.py,sha256=Foi_K-sAqHWsIAAaxY2Tb0hyXnMCi06LqIFCPiBS5n0,7627
3
- haiku/rag/chunker.py,sha256=lSSPWgNAe7gNZL_yNLmDtqxJix4YclOiG7gbARcEpV8,1871
4
- haiku/rag/cli.py,sha256=9F64IIm2c1nBKn7p9D5yYkVZr8HcjDemrzjF9SRGIY8,5017
5
- haiku/rag/client.py,sha256=qoVgdsP_MH8wVcDTvPIcMgW7323tTjOXH8JKugz5snY,10847
6
- haiku/rag/config.py,sha256=ctD_pu7nDOieirJofhNMO-OJIONLC5myvcru9iTm_ps,1433
7
- haiku/rag/logging.py,sha256=zTTGpGq5tPdcd7RpCbd9EGw1IZlQDbYkrCg9t9pqRc4,580
8
- haiku/rag/mcp.py,sha256=tMN6fNX7ZtAER1R6DL1GkC9HZozTC4HzuQs199p7icI,4551
9
- haiku/rag/monitor.py,sha256=r386nkhdlsU8UECwIuVwnrSlgMk3vNIuUZGNIzkZuec,2770
10
- haiku/rag/reader.py,sha256=S7-Z72pDvSHedvgt4-RkTOwZadG88Oed9keJ69SVITk,962
11
- haiku/rag/utils.py,sha256=6xVM6z2OmhzB4FEDlPbMsr_ZBBmCbMQb83nP6E2UdxY,629
12
- haiku/rag/embeddings/__init__.py,sha256=4jUPe2FyIf8BGZ7AncWSlBdNXG3URejBbnkhQf3JiD0,1505
13
- haiku/rag/embeddings/base.py,sha256=PTAWKTU-Q-hXIhbRK1o6pIdpaW7DFdzJXQ0Nzc6VI-w,379
14
- haiku/rag/embeddings/ollama.py,sha256=hWdrTiuJwNSRYCqP0WP-z6XXA3RBGkAiknZMsPLH0qU,441
15
- haiku/rag/embeddings/openai.py,sha256=reh8AykG2f9f5hhRDmqSsjiuCPi9SsXfe2YEZFlxXk8,550
16
- haiku/rag/embeddings/voyageai.py,sha256=jc0JywdLJD3Ee1MUv1m8MhWCEo0enNnVcrIBtUvD-Ss,534
17
- haiku/rag/qa/__init__.py,sha256=oso98Ypti7mBLTJ6Zk71YaSJ9Rgc89QXp9RSB6zSpYs,1501
18
- haiku/rag/qa/anthropic.py,sha256=6I6cf6ySNkYbmDFdy22sA8r3GO5moiiH75tJnHcgJQA,4448
19
- haiku/rag/qa/base.py,sha256=4ZTM_l5FAZ9cA0f8NeqRJiUAmjatwCTmSoclFw0gTFQ,1349
20
- haiku/rag/qa/ollama.py,sha256=-UtNFErYlA_66g3WLU6lK38a1Y5zhAL6s_uZ5AP0TFs,2381
21
- haiku/rag/qa/openai.py,sha256=dF32sGgVt8mZi5oVxByaeECs9NqLjvDiZnnpJBsrHm8,3968
22
- haiku/rag/qa/prompts.py,sha256=578LJGZJ0LQ_q7ccyj5hLabtHo8Zcfw5-DiLGN9lC-w,1200
23
- haiku/rag/store/__init__.py,sha256=hq0W0DAC7ysqhWSP2M2uHX8cbG6kbr-sWHxhq6qQcY0,103
24
- haiku/rag/store/engine.py,sha256=BeYZRZ08zaYeeu375ysnAL3tGz4roA3GzP7WRNwznCo,2603
25
- haiku/rag/store/models/__init__.py,sha256=s0E72zneGlowvZrFWaNxHYjOAUjgWdLxzdYsnvNRVlY,88
26
- haiku/rag/store/models/chunk.py,sha256=lmbPOOTz-N4PXhrA5XCUxyRcSTZBo135fqkV1mwnGcE,309
27
- haiku/rag/store/models/document.py,sha256=TVXVY-nQs-1vCORQEs9rA7zOtndeGC4dgCoujLAS054,396
28
- haiku/rag/store/repositories/__init__.py,sha256=uIBhxjQh-4o3O-ck8b7BQ58qXQTuJdPvrDIHVhY5T1A,263
29
- haiku/rag/store/repositories/base.py,sha256=cm3VyQXhtxvRfk1uJHpA0fDSxMpYN-mjQmRiDiLsQ68,1008
30
- haiku/rag/store/repositories/chunk.py,sha256=gik7ZPOK3gCoG6tU1pGueAZBPmJxIb7obYFUhwINrYg,16497
31
- haiku/rag/store/repositories/document.py,sha256=xpWOpjHFbhVwNJ1gpusEKNY6l_Qyibg9y_bdHCwcfpk,7133
32
- haiku_rag-0.3.3.dist-info/METADATA,sha256=nDI-sy2F8h7qr9hK1S7VQLOMRcWYP1clxJYxNVB1AaA,4019
33
- haiku_rag-0.3.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
34
- haiku_rag-0.3.3.dist-info/entry_points.txt,sha256=G1U3nAkNd5YDYd4v0tuYFbriz0i-JheCsFuT9kIoGCI,48
35
- haiku_rag-0.3.3.dist-info/licenses/LICENSE,sha256=eXZrWjSk9PwYFNK9yUczl3oPl95Z4V9UXH7bPN46iPo,1065
36
- haiku_rag-0.3.3.dist-info/RECORD,,