Skip to content

Commit c62d6d0

Browse files
author
Daniele Briggi
committed
chore: implement list command
- reorganize project
1 parent 88d379d commit c62d6d0

21 files changed

Lines changed: 401 additions & 119 deletions

.devcontainer/devcontainer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@
1414
"hbenl.vscode-test-explorer"
1515
]
1616
}
17-
}
17+
},
18+
"runArgs": ["--network=host"]
1819
}

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ dev = [
2424
]
2525

2626
[project.scripts]
27-
sqlite-rag = "src.cli:cli"
27+
sqlite-rag = "sqlite_rag.cli:cli"
2828

2929
[tool.setuptools.packages.find]
30-
where = ["."]
31-
include = ["src*"]
30+
where = ["src"]
31+
include = ["sqlite_rag*"]
3232

3333
[tool.isort]
3434
# make the tools compatible to each other

src/__init__.py

Whitespace-only changes.

src/engine.py

Lines changed: 0 additions & 55 deletions
This file was deleted.

src/sqlite_rag/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""
2+
SQLite RAG - Hybrid search with SQLite AI and SQLite Vector
3+
4+
This package provides both a library interface and CLI for document
5+
indexing and semantic search using SQLite with AI and vector extensions.
6+
"""
7+
8+
from .sqliterag import SQLiteRag
9+
from .models.document import Document
10+
from .models.chunk import Chunk
11+
from .settings import Settings
12+
13+
__version__ = "0.1.0"
14+
__all__ = [
15+
"SQLiteRag",
16+
"Document",
17+
"Chunk",
18+
"Settings",
19+
]

src/chunker.py renamed to src/sqlite_rag/chunker.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import sqlite3
22
from typing import List
33

4-
from models.chunk import Chunk
5-
from settings import Settings
4+
from .models.chunk import Chunk
5+
from .settings import Settings
66
import math
77

88

src/cli.py renamed to src/sqlite_rag/cli.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
#!/usr/bin/env python3
2-
from pathlib import Path
32
import shlex
43
import sys
4+
from pathlib import Path
55
from typing import Optional
66

77
import typer
88

9-
from sqliterag import SQLiteRag
9+
from .sqliterag import SQLiteRag
1010

1111

1212
class CLI:
@@ -39,16 +39,39 @@ def add(
3939

4040

4141
@app.command()
42-
def add_text(text: str, uri: Optional[str] = None, metadata: dict = {}):
42+
def add_text(text: str, uri: Optional[str] = None):
4343
"""Add a text to the database"""
4444
rag = SQLiteRag()
45-
rag.add_text(text, uri=uri, metadata=metadata)
45+
rag.add_text(text, uri=uri, metadata={})
4646

4747

4848
@app.command("list")
4949
def list_documents():
5050
"""List all documents in the database"""
51-
pass
51+
rag = SQLiteRag()
52+
documents = rag.list_documents()
53+
54+
if not documents:
55+
typer.echo("No documents found in the database.")
56+
return
57+
58+
# Print stats
59+
typer.echo(f"Total documents: {len(documents)}")
60+
typer.echo("Documents:")
61+
typer.echo("-" * 106)
62+
63+
typer.echo(f"{'ID':<36} {'URI/Content':<50} {'Created At':<20}")
64+
typer.echo("-" * 106)
65+
66+
for doc in documents:
67+
# Show URI if available, otherwise show first chars of content
68+
uri_or_content = doc.uri or (
69+
doc.content[:47] + "..." if len(doc.content) > 47 else doc.content
70+
)
71+
created_at = (
72+
doc.created_at.strftime("%Y-%m-%d %H:%M:%S") if doc.created_at else "N/A"
73+
)
74+
typer.echo(f"{doc.id:<36} {uri_or_content:<50} {created_at:<20}")
5275

5376

5477
@app.command()
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import sqlite3
22
from pathlib import Path
33

4-
from settings import Settings
4+
from .settings import Settings
55

66

77
class Database:
@@ -12,9 +12,9 @@ def initialize(conn: sqlite3.Connection, settings: Settings) -> sqlite3.Connecti
1212
"""Initialize the database with extensions and schema"""
1313
conn.enable_load_extension(True)
1414
try:
15-
conn.load_extension(str(Path(__file__).parent.parent / "extensions" / "ai"))
15+
conn.load_extension(str(Path(__file__).parent.parent.parent / "extensions" / "ai"))
1616
conn.load_extension(
17-
str(Path(__file__).parent.parent / "extensions" / "vector")
17+
str(Path(__file__).parent.parent.parent / "extensions" / "vector")
1818
)
1919
except sqlite3.OperationalError as e:
2020
raise RuntimeError(

src/sqlite_rag/engine.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import sqlite3
2+
from pathlib import Path
3+
4+
from .chunker import Chunker
5+
from .models.chunk import Chunk
6+
from .models.document import Document
7+
from .settings import Settings
8+
9+
10+
class Engine:
11+
def __init__(self, conn: sqlite3.Connection, settings: Settings, chunker: Chunker):
12+
self._conn = conn
13+
self.settings = settings
14+
self._chunker = chunker
15+
16+
def load_model(self):
17+
"""Load the model model from the specified path
18+
or download it from Hugging Face if not found."""
19+
20+
model_path = Path(self.settings.model_path_or_name)
21+
if not model_path.exists():
22+
raise FileNotFoundError(f"Model file not found at {model_path}")
23+
24+
# model_path = self.settings.model_path_or_name
25+
# if not Path(self.settings.model_path_or_name).exists():
26+
# # check if exists locally or try to download it from Hugging Face
27+
# model_path = hf_hub_download(
28+
# repo_id=self.settings.model_path_or_name,
29+
# filename="model-q4_0.gguf", # GGUF format
30+
# cache_dir="./models"
31+
# )
32+
33+
self._conn.execute(
34+
f"SELECT llm_model_load('{self.settings.model_path_or_name}', '{self.settings.model_config}');"
35+
)
36+
37+
def process(self, document: Document) -> Document:
38+
chunks = self._chunker.chunk(document.content)
39+
chunks = self.generate_embedding(chunks)
40+
document.chunks = chunks
41+
return document
42+
43+
# TODO: better to get a list of str and return a list of embeddings?
44+
def generate_embedding(self, chunks: list[Chunk]) -> list[Chunk]:
45+
"""Generate embedding for the given text."""
46+
cursor = self._conn.cursor()
47+
48+
for chunk in chunks:
49+
cursor.execute("SELECT llm_embed_generate(?, 'json_output=0')", (chunk.content,))
50+
result = cursor.fetchone()
51+
52+
if result is None:
53+
raise RuntimeError("Failed to generate embedding.")
54+
55+
chunk.embedding = result[0]
56+
57+
return chunks
58+
59+
def quantize(self) -> None:
60+
"""Quantitize stored vector for faster search via quantitize scan."""
61+
cursor = self._conn.cursor()
62+
63+
cursor.execute("SELECT vector_quantize('chunks', 'embedding');")
64+
65+
self._conn.commit()
66+
67+
def search(self, query: str, limit: int = 10) -> list[Document]:
68+
"""Semantic search and full-text search sorted with Reciprocal Rank Fusion."""
69+
cursor = self._conn.cursor()
70+
71+
query_embedding = self.generate_embedding([Chunk(content=query)])[0].embedding
72+
73+
cursor.execute(
74+
# TODO: use vector_convert_XXX to convert the query to the correct type
75+
"""
76+
-- sqlite-vector KNN vector search results
77+
WITH vec_matches AS (
78+
SELECT
79+
row_number() over (order by v.distance) AS rank_number,
80+
c.document_id,
81+
v.distance
82+
FROM chunks AS c
83+
JOIN vector_quantize_scan('chunks', 'embedding', vector_convert_f32(:query_embedding), :k) AS v
84+
ON c.id = v.rowid
85+
),
86+
-- Full-text search results
87+
fts_matches AS (
88+
SELECT
89+
row_number() over (order by rank) AS rank_number,
90+
c.document_id,
91+
rank AS score
92+
FROM chunks_fts AS c_fts
93+
JOIN chunks AS c ON c_fts.rowid = c.id
94+
WHERE c_fts.content MATCH :query
95+
LIMIT :k
96+
),
97+
-- combine FTS5 + vector search results with RRF
98+
matches AS (
99+
SELECT
100+
documents.id,
101+
documents.uri,
102+
documents.content,
103+
vec_matches.rank_number AS vec_rank,
104+
fts_matches.rank_number AS fts_rank,
105+
-- Reciprocal Rank Fusion score
106+
(
107+
COALESCE(1.0 / (:rrf_k + vec_matches.rank_number), 0.0) * :weight_vec +
108+
COALESCE(1.0 / (:rrf_k + fts_matches.rank_number), 0.0) * :weight_fts
109+
) AS combined_rank,
110+
vec_matches.distance AS vec_distance,
111+
fts_matches.score AS fts_score
112+
FROM vec_matches
113+
FULL OUTER JOIN fts_matches
114+
ON vec_matches.document_id = fts_matches.document_id
115+
JOIN documents ON documents.id = COALESCE(vec_matches.document_id, fts_matches.document_id)
116+
ORDER BY combined_rank DESC
117+
)
118+
SELECT
119+
id,
120+
uri,
121+
content,
122+
vec_rank,
123+
fts_rank,
124+
combined_rank,
125+
vec_distance,
126+
fts_score
127+
FROM matches;
128+
""",
129+
{
130+
"query": f'"{query}"',
131+
"query_embedding": query_embedding,
132+
"k": limit,
133+
# TODO: move to settings or costants
134+
"rrf_k": 60,
135+
"weight_fts": 1.0,
136+
"weight_vec": 1.0,
137+
},
138+
)
139+
140+
rows = cursor.fetchall()
141+
return [
142+
Document(
143+
id=row[0],
144+
uri=row[1],
145+
content=row[2],
146+
vec_rank=row[3],
147+
fts_rank=row[4],
148+
combined_rank=row[5],
149+
vec_distance=row[6],
150+
fts_score=row[7],
151+
)
152+
for row in rows
153+
]

0 commit comments

Comments
 (0)