Skip to content

Commit 88d379d

Browse files
author
Daniele Briggi
committed
feat(cli): implement add with recursion, add_text and list
1 parent eae709d commit 88d379d

11 files changed

Lines changed: 249 additions & 35 deletions

File tree

src/cli.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#!/usr/bin/env python3
2+
from pathlib import Path
23
import shlex
34
import sys
5+
from typing import Optional
46

57
import typer
68

@@ -25,18 +27,22 @@ def __call__(self, *args, **kwds):
2527

2628

2729
@app.command()
28-
def add(path: str):
30+
def add(
31+
path: str = typer.Argument(..., help="File or directory path to add"),
32+
recursive: bool = typer.Option(
33+
False, "-r", "--recursive", help="Recursively add all files in directories"
34+
),
35+
):
2936
"""Add a file path to the database"""
3037
rag = SQLiteRag()
31-
rag.add(path)
38+
rag.add(path, recursively=recursive)
3239

3340

3441
@app.command()
35-
def add_text(text: str):
42+
def add_text(text: str, uri: Optional[str] = None, metadata: dict = {}):
3643
"""Add a text to the database"""
37-
# rag = SQLiteRag()
38-
# rag.add_text(text)
39-
pass
44+
rag = SQLiteRag()
45+
rag.add_text(text, uri=uri, metadata=metadata)
4046

4147

4248
@app.command("list")

src/engine.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import sqlite3
2-
from anyio import Path
3-
from huggingface_hub import hf_hub_download
4-
from repository import Repository
2+
from pathlib import Path
3+
4+
from models.chunk import Chunk
55
from settings import Settings
66

77

@@ -13,7 +13,11 @@ def __init__(self, conn: sqlite3.Connection, settings: Settings):
1313
def load_model(self):
1414
"""Load the model model from the specified path
1515
or download it from Hugging Face if not found."""
16-
pass
16+
17+
model_path = Path(self.settings.model_path_or_name)
18+
if not model_path.exists():
19+
raise FileNotFoundError(f"Model file not found at {model_path}")
20+
1721
# model_path = self.settings.model_path_or_name
1822
# if not Path(self.settings.model_path_or_name).exists():
1923
# # check if exists locally or try to download it from Hugging Face
@@ -27,4 +31,25 @@ def load_model(self):
2731
f"SELECT llm_model_load('{self.settings.model_path_or_name}', '{self.settings.model_config}');"
2832
)
2933

30-
34+
def generate_embedding(self, chunks: list[Chunk]) -> list[Chunk]:
35+
"""Generate embedding for the given text."""
36+
cursor = self._conn.cursor()
37+
38+
for chunk in chunks:
39+
cursor.execute("SELECT llm_embed_generate(?)", (chunk.content,))
40+
result = cursor.fetchone()
41+
42+
if result is None:
43+
raise RuntimeError("Failed to generate embedding.")
44+
45+
chunk.embedding = result[0]
46+
47+
return chunks
48+
49+
def quantize(self) -> None:
50+
"""Quantitize stored vector for faster search via quantitize scan."""
51+
cursor = self._conn.cursor()
52+
53+
cursor.execute("SELECT vector_quantize('chunks', 'embedding');")
54+
55+
self._conn.commit()

src/reader.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ class FileReader:
3939
".yml",
4040
]
4141

42+
@staticmethod
43+
def is_supported(path: Path) -> bool:
44+
"""Check if the file extension is supported"""
45+
return path.suffix.lower() in FileReader.extensions
46+
4247
@staticmethod
4348
def parse_file(path: Path) -> str:
4449
try:

src/repository.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import json
2-
from pathlib import Path
32
import sqlite3
43
from uuid import uuid4
54

@@ -12,16 +11,6 @@ def __init__(self, conn: sqlite3.Connection, settings: Settings):
1211
self._conn = conn
1312
self.settings = settings
1413

15-
def load_model(self, path: str):
16-
"""Load the LLMA model"""
17-
model_path = Path(path)
18-
if not model_path.exists():
19-
raise FileNotFoundError(f"Model file not found at {model_path}")
20-
21-
self._conn.execute(
22-
f"SELECT llm_model_load('{model_path}', '{self.settings.model_config}');"
23-
)
24-
2514
def add_document(self, document: Document) -> str:
2615
"""Add a text content to the database"""
2716
cursor = self._conn.cursor()
@@ -46,5 +35,25 @@ def add_document(self, document: Document) -> str:
4635
)
4736

4837
self._conn.commit()
49-
38+
5039
return document_id
40+
41+
def list_documents(self) -> list[Document]:
42+
"""List all documents in the database"""
43+
cursor = self._conn.cursor()
44+
cursor.execute("SELECT id, content, uri, metadata FROM documents")
45+
rows = cursor.fetchall()
46+
47+
documents = []
48+
for row in rows:
49+
doc_id, content, uri, metadata = row
50+
documents.append(
51+
Document(
52+
id=doc_id,
53+
content=content,
54+
uri=uri,
55+
metadata=json.loads(metadata),
56+
)
57+
)
58+
59+
return documents

src/settings.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ def __init__(self, model_path_or_name: str, db_path: str = "sqliterag.db"):
66
self.embedding_dim = 384 # Default for all-MiniLM-L6-v2
77
self.vector_type = "FLOAT32" # Default vector type for sqlite-vector
88

9-
self.model_config = "n_ctx=0" # See: https://github.com/sqliteai/sqlite-ai/blob/e172b9c9b9d76435be635d1e02c1e88b3681cc6e/src/sqlite-ai.c#L51-L57
10-
11-
self.chunk_size = 1000 # Maximum tokens per chunk
12-
self.chunk_overlap = 200 # Token overlap between chunks
9+
self.model_config = "n_ctx=128" # See: https://github.com/sqliteai/sqlite-ai/blob/e172b9c9b9d76435be635d1e02c1e88b3681cc6e/src/sqlite-ai.c#L51-L57
10+
11+
self.chunk_size = 256 # Maximum tokens per chunk
12+
self.chunk_overlap = 32 # Token overlap between chunks
13+
14+
self.quantize_scan = True # Whether to quantize the vector for faster search

src/sqliterag.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import glob
12
from pathlib import Path
23
import sqlite3
34
from typing import Optional
@@ -36,18 +37,62 @@ def _ensure_initialized(self):
3637

3738
self.ready = True
3839

39-
def add(self, path: str):
40+
def add(self, path: str, recursively: bool = False):
4041
"""Add the file content into the database"""
4142
self._ensure_initialized()
4243

4344
if not Path(path).exists():
44-
raise FileNotFoundError(f"File {path} does not exist.")
45+
raise FileNotFoundError(f"{path} does not exist.")
4546

46-
# TODO: check the file extension
47-
content = FileReader.parse_file(Path(path))
48-
# TODO: include metadata extraction and mdx options (see our docsearch)
49-
document = Document(content=content, uri=path)
47+
files_to_process: list[Path] = []
48+
path_obj = Path(path)
49+
50+
if path_obj.is_file():
51+
files_to_process.append(path_obj)
52+
elif path_obj.is_dir():
53+
if recursively:
54+
files_to_process = list(path_obj.rglob("*"))
55+
else:
56+
files_to_process = list(path_obj.glob("*"))
57+
58+
files_to_process = [
59+
f
60+
for f in files_to_process
61+
if f.is_file() and FileReader.is_supported(f)
62+
]
63+
64+
for file_path in files_to_process:
65+
# TODO: check the file extension
66+
content = FileReader.parse_file(file_path)
67+
# TODO: include metadata extraction and mdx options (see our docsearch)
68+
document = Document(content=content, uri=str(file_path))
69+
chunks = self._chunker.chunk(document.content)
70+
chunks = self._engine.generate_embedding(chunks)
71+
document.chunks = chunks
72+
73+
self._repository.add_document(document)
74+
75+
if self.settings.quantize_scan:
76+
self._engine.quantize()
77+
78+
def add_text(
79+
self, text: str, uri: Optional[str] = None, metadata: dict = {}
80+
) -> None:
81+
"""Add a text content into the database"""
82+
self._ensure_initialized()
83+
84+
document = Document(content=text, uri=uri, metadata=metadata)
5085
chunks = self._chunker.chunk(document.content)
86+
chunks = self._engine.generate_embedding(chunks)
5187
document.chunks = chunks
5288

5389
self._repository.add_document(document)
90+
91+
if self.settings.quantize_scan:
92+
self._engine.quantize()
93+
94+
def list_documents(self) -> list[Document]:
95+
"""List all documents in the database"""
96+
self._ensure_initialized()
97+
98+
return self._repository.list_documents()

tests/conftest.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
@pytest.fixture
1111
def db_conn():
1212
with tempfile.NamedTemporaryFile(suffix=".db") as tmp_db:
13-
settings = Settings(model_path_or_name="all-MiniLM-L6-v2", db_path=tmp_db.name)
13+
settings = Settings(
14+
model_path_or_name="./capybarahermes-2.5-mistral-7b.Q4_K_M.gguf",
15+
db_path=tmp_db.name,
16+
)
1417

1518
conn = sqlite3.connect(settings.db_path)
1619
Database.initialize(conn, settings)
@@ -23,5 +26,5 @@ def db_conn():
2326
@pytest.fixture
2427
def db_settings() -> Settings:
2528
with tempfile.NamedTemporaryFile(suffix=".db") as tmp_db:
26-
settings = Settings(model_path_or_name="all-MiniLM-L6-v2", db_path=tmp_db.name)
29+
settings = Settings(model_path_or_name="./capybarahermes-2.5-mistral-7b.Q4_K_M.gguf", db_path=tmp_db.name)
2730
return settings

tests/integration/test_cli.py

Whitespace-only changes.

tests/test_engine.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from engine import Engine
2+
from models.chunk import Chunk
3+
4+
5+
class TestEngine:
6+
def test_generate_embedding(self, db_conn):
7+
conn, settings = db_conn
8+
engine = Engine(conn, settings)
9+
engine.load_model()
10+
11+
# Create a sample chunk
12+
chunk = Chunk(content="This is a test chunk for embedding generation.")
13+
14+
# Generate embedding
15+
result_chunks = engine.generate_embedding([chunk])
16+
17+
assert len(result_chunks) == 1
18+
assert result_chunks[0].embedding is not None
19+
assert isinstance(result_chunks[0].embedding, bytes)

tests/test_repository.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,31 @@ def test_add_document_with_chunks(self, db_conn):
7676
assert chunk_rows[0][1] == b"\x00" * 384
7777
assert chunk_rows[1][0] == "Chunk 2 content"
7878
assert chunk_rows[1][1] == b"\x00" * 384
79+
80+
def test_list_documents(self, db_conn):
81+
conn, settings = db_conn
82+
83+
repo = Repository(conn, settings)
84+
85+
doc1 = Document(
86+
content="Document 1 content.", uri="doc1.txt", metadata={"author": "test1"}
87+
)
88+
doc2 = Document(
89+
content="Document 2 content.", uri="doc2.txt", metadata={"author": "test2"}
90+
)
91+
92+
repo.add_document(doc1)
93+
repo.add_document(doc2)
94+
95+
documents = repo.list_documents()
96+
97+
assert len(documents) == 2
98+
assert documents[0].id is not None
99+
assert documents[0].uri == "doc1.txt"
100+
assert documents[0].content == "Document 1 content."
101+
assert documents[0].metadata == {"author": "test1"}
102+
103+
assert documents[1].id is not None
104+
assert documents[1].content == "Document 2 content."
105+
assert documents[1].uri == "doc2.txt"
106+
assert documents[1].metadata == {"author": "test2"}

0 commit comments

Comments
 (0)