Skip to content

Commit feb738b

Browse files
author
Daniele Briggi
committed
feat: add document existence check
- Modified Chunker to fetch token count using named columns. - FileReader to handle UTF-8 files with Unicode characters.
1 parent 6a4ea9f commit feb738b

15 files changed

Lines changed: 157 additions & 72 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
semsearch/
2+
docs/
23

34
# LLM models
45
*.gguf

src/sqlite_rag/chunker.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ def _get_token_count(self, text: str) -> int:
2222
"""Get token count using SQLite AI extension."""
2323
if text == "":
2424
return 0
25-
cursor = self._conn.execute("SELECT llm_token_count(?)", (text,))
26-
return cursor.fetchone()[0]
25+
cursor = self._conn.execute("SELECT llm_token_count(?) AS count", (text,))
26+
return cursor.fetchone()['count']
2727

2828
def _estimate_tokens_count(self, text: str) -> int:
2929
"""Estimate token count more conservatively."""

src/sqlite_rag/cli.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,11 @@ def rebuild(
122122
"""Rebuild embeddings and full-text index"""
123123
rag = SQLiteRag()
124124

125-
typer.echo("Starting rebuild process...")
125+
typer.echo("Rebuild process...")
126126

127127
result = rag.rebuild(remove_missing=remove_missing)
128128

129-
typer.echo(f"Rebuild completed:")
129+
typer.echo("Rebuild completed:")
130130
typer.echo(f" Total documents: {result['total']}")
131131
typer.echo(f" Reprocessed: {result['reprocessed']}")
132132
typer.echo(f" Not found: {result['not_found']}")
@@ -176,14 +176,12 @@ def search(
176176
return
177177

178178
typer.echo(f"Found {len(results)} documents:")
179-
# print the position, the snippet and the uri as a table
180-
typer.echo(f"{'Position':<10} {'Content':<50}")
181-
typer.echo("-" * 60)
182-
for i, doc in enumerate(results, start=1):
183-
uri_or_content = doc.uri or (
184-
doc.content[:47] + "..." if len(doc.content) > 47 else doc.content
185-
)
186-
typer.echo(f"{i:<10} {uri_or_content:<50}")
179+
typer.echo(f"{'Pos':<4} {'Preview':<60} {'URI':<50}")
180+
typer.echo("-" * 116)
181+
for idx, doc in enumerate(results, 1):
182+
snippet = f"{doc.snippet[:57]!r}" + "..." if len(doc.snippet) > 60 else f"{doc.snippet!r}"
183+
uri = doc.document.uri or "N/A"
184+
typer.echo(f"{idx:<4} {snippet:<60} {uri:<50}")
187185

188186

189187
def repl_mode():

src/sqlite_rag/database.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def _create_schema(conn: sqlite3.Connection, settings: Settings):
6363
cursor.execute(
6464
"""
6565
CREATE TABLE IF NOT EXISTS chunks (
66+
id INTEGER PRIMARY KEY AUTOINCREMENT,
6667
document_id TEXT,
6768
content TEXT,
6869
embedding BLOB,
@@ -73,7 +74,7 @@ def _create_schema(conn: sqlite3.Connection, settings: Settings):
7374

7475
cursor.execute(
7576
"""
76-
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(content, content='chunks', content_rowid='rowid');
77+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(content, content='chunks', content_rowid='id');
7778
"""
7879
)
7980

src/sqlite_rag/engine.py

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import re
12
import sqlite3
23
from pathlib import Path
34

5+
from sqlite_rag.models.document_result import DocumentResult
6+
47
from .chunker import Chunker
58
from .models.chunk import Chunk
69
from .models.document import Document
@@ -46,13 +49,13 @@ def generate_embedding(self, chunks: list[Chunk]) -> list[Chunk]:
4649
cursor = self._conn.cursor()
4750

4851
for chunk in chunks:
49-
cursor.execute("SELECT llm_embed_generate(?)", (chunk.content,))
52+
cursor.execute("SELECT llm_embed_generate(?) AS embedding", (chunk.content,))
5053
result = cursor.fetchone()
5154

5255
if result is None:
5356
raise RuntimeError("Failed to generate embedding.")
5457

55-
chunk.embedding = result[0]
58+
chunk.embedding = result['embedding']
5659

5760
return chunks
5861

@@ -64,42 +67,40 @@ def quantize(self) -> None:
6467

6568
self._conn.commit()
6669

67-
def search(self, query: str, limit: int = 10) -> list[Document]:
70+
def search(self, query: str, limit: int = 10) -> list[DocumentResult]:
6871
"""Semantic search and full-text search sorted with Reciprocal Rank Fusion."""
6972
cursor = self._conn.cursor()
7073

7174
query_embedding = self.generate_embedding([Chunk(content=query)])[0].embedding
7275

76+
# Clean up and split into words
77+
query = " ".join(re.findall(r"\b\w+\b", query.lower()))
78+
7379
cursor.execute(
7480
# TODO: use vector_convert_XXX to convert the query to the correct type
7581
"""
7682
-- sqlite-vector KNN vector search results
7783
WITH vec_matches AS (
7884
SELECT
85+
v.rowid AS chunk_id,
7986
row_number() over (order by v.distance) AS rank_number,
80-
c.document_id,
8187
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.rowid = v.rowid
88+
FROM vector_quantize_scan('chunks', 'embedding', vector_convert_f32(:query_embedding), :k) AS v
8589
),
8690
-- Full-text search results
8791
fts_matches AS (
8892
SELECT
93+
chunks_fts.rowid AS chunk_id,
8994
row_number() over (order by rank) AS rank_number,
90-
c.document_id,
9195
rank AS score
92-
FROM chunks_fts AS c_fts
93-
JOIN chunks AS c ON c_fts.rowid = c.rowid
94-
WHERE c_fts.content MATCH :query
96+
FROM chunks_fts
97+
WHERE chunks_fts MATCH :query
9598
LIMIT :k
9699
),
97100
-- combine FTS5 + vector search results with RRF
98101
matches AS (
99102
SELECT
100-
documents.id,
101-
documents.uri,
102-
documents.content,
103+
COALESCE(vec_matches.chunk_id, fts_matches.chunk_id) AS chunk_id,
103104
vec_matches.rank_number AS vec_rank,
104105
fts_matches.rank_number AS fts_rank,
105106
-- Reciprocal Rank Fusion score
@@ -111,22 +112,26 @@ def search(self, query: str, limit: int = 10) -> list[Document]:
111112
fts_matches.score AS fts_score
112113
FROM vec_matches
113114
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
115+
ON vec_matches.chunk_id = fts_matches.chunk_id
117116
)
118117
SELECT
119-
id,
120-
uri,
121-
content,
118+
documents.id,
119+
documents.uri,
120+
documents.content as document_content,
121+
chunks.content AS snippet,
122122
vec_rank,
123123
fts_rank,
124124
combined_rank,
125125
vec_distance,
126126
fts_score
127-
FROM matches;
127+
FROM matches
128+
JOIN chunks ON chunks.id = matches.chunk_id
129+
JOIN documents ON documents.id = chunks.document_id
130+
ORDER BY combined_rank DESC
131+
;
128132
""",
129133
{
134+
# '*' is used to match while typing
130135
"query": query + "*",
131136
"query_embedding": query_embedding,
132137
"k": limit,
@@ -139,15 +144,18 @@ def search(self, query: str, limit: int = 10) -> list[Document]:
139144

140145
rows = cursor.fetchall()
141146
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],
147+
DocumentResult(
148+
document=Document(
149+
id=row["id"],
150+
uri=row["uri"],
151+
content=row["document_content"],
152+
),
153+
snippet=row["snippet"],
154+
vec_rank=row["vec_rank"],
155+
fts_rank=row["fts_rank"],
156+
combined_rank=row["combined_rank"],
157+
vec_distance=row["vec_distance"],
158+
fts_score=row["fts_score"],
151159
)
152160
for row in rows
153161
]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from attr import dataclass
2+
3+
from .document import Document
4+
5+
6+
@dataclass
7+
class DocumentResult:
8+
document: Document
9+
10+
snippet: str
11+
12+
combined_rank: float
13+
vec_rank: float | None = None
14+
fts_rank: float | None = None
15+
16+
vec_distance: float | None = None
17+
fts_score: float | None = None

src/sqlite_rag/reader.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from pathlib import Path
22

3-
from markitdown import MarkItDown
3+
from markitdown import MarkItDown, StreamInfo
44

55

66
class FileReader:
@@ -47,8 +47,8 @@ def is_supported(path: Path) -> bool:
4747
@staticmethod
4848
def parse_file(path: Path) -> str:
4949
try:
50-
reader = MarkItDown()
51-
return reader.convert(path).text_content
50+
converter = MarkItDown()
51+
return converter.convert(path, stream_info=StreamInfo(charset='utf8')).text_content
5252
except Exception as exc:
5353
raise ValueError(f"Failed to parse file {path}") from exc
5454

src/sqlite_rag/repository.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,21 @@ def find_document_by_id_or_uri(self, identifier: str) -> Document | None:
8282
)
8383
return None
8484

85+
def document_exists_by_hash(self, hash: str) -> bool:
86+
"""Check if a document with the given hash exists"""
87+
cursor = self._conn.cursor()
88+
cursor.execute("SELECT 1 FROM documents WHERE hash = ?", (hash,))
89+
return cursor.fetchone() is not None
90+
8591
def remove_document(self, document_id: str) -> bool:
8692
"""Remove document and its chunks by document ID"""
8793
cursor = self._conn.cursor()
8894

8995
# Check if document exists
90-
cursor.execute("SELECT COUNT(*) FROM documents WHERE id = ?", (document_id,))
91-
if cursor.fetchone()[0] == 0:
96+
cursor.execute(
97+
"SELECT COUNT(*) AS total FROM documents WHERE id = ?", (document_id,)
98+
)
99+
if cursor.fetchone()["total"] == 0:
92100
return False
93101

94102
# Remove chunks first

src/sqlite_rag/settings.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ def __init__(self, model_path_or_name: str, db_path: str = "sqliterag.db"):
33
self.model_path_or_name = model_path_or_name
44
self.db_path = db_path
55

6-
self.embedding_dim = 384 # Default for all-MiniLM-L6-v2
7-
self.vector_type = "FLOAT32" # Default vector type for sqlite-vector
6+
self.embedding_dim = 384
7+
self.vector_type = "FLOAT32"
88

9-
self.model_config = "n_ctx=384,generate_embedding=1" # See: https://github.com/sqliteai/sqlite-ai/blob/e172b9c9b9d76435be635d1e02c1e88b3681cc6e/src/sqlite-ai.c#L51-L57
9+
self.model_config = "n_ctx=384" # See: https://github.com/sqliteai/sqlite-ai/blob/e172b9c9b9d76435be635d1e02c1e88b3681cc6e/src/sqlite-ai.c#L51-L57
1010

1111
self.chunk_size = 256 # Maximum tokens per chunk
1212
self.chunk_overlap = 32 # Token overlap between chunks
1313

1414
self.quantize_scan = True # Whether to quantize the vector for faster search
15+

src/sqlite_rag/sqliterag.py

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import re
21
import sqlite3
32
from pathlib import Path
43
from typing import Optional
54

65
from sqlite_rag.logger import Logger
6+
from sqlite_rag.models.document_result import DocumentResult
77

88
from .chunker import Chunker
99
from .database import Database
@@ -26,13 +26,19 @@ def __init__(self, settings: Optional[Settings] = None):
2626
self.settings = settings
2727
self._logger = Logger()
2828

29-
self._conn = sqlite3.connect(settings.db_path)
29+
self._conn = self._create_db_connection()
3030

3131
self._repository = Repository(self._conn, settings)
3232
self._chunker = Chunker(self._conn, settings)
3333
self._engine = Engine(self._conn, settings, chunker=self._chunker)
3434

3535
self.ready = False
36+
37+
def _create_db_connection(self) -> sqlite3.Connection:
38+
conn = sqlite3.connect(self.settings.db_path)
39+
conn.row_factory = sqlite3.Row
40+
41+
return conn
3642

3743
def _ensure_initialized(self):
3844
if not self.ready:
@@ -54,9 +60,14 @@ def add(self, path: str, recursive: bool = False) -> int:
5460
for file_path in files_to_process:
5561
# TODO: include metadata extraction and mdx options (see our docsearch)
5662
content = FileReader.parse_file(file_path)
57-
document = self._engine.process(
58-
Document(content=content, uri=str(file_path))
59-
)
63+
document = Document(content=content, uri=str(file_path.absolute()))
64+
65+
exists = self._repository.document_exists_by_hash(document.hash())
66+
if exists:
67+
self._logger.info(f"Unchanged document: {file_path}")
68+
continue
69+
70+
document = self._engine.process(document)
6071

6172
self._repository.add_document(document)
6273
self._logger.info(str(file_path))
@@ -145,7 +156,9 @@ def rebuild(self, remove_missing: bool = False) -> dict:
145156
self._repository.add_document(processed_doc)
146157

147158
reprocessed += 1
148-
self._logger.debug(f"Reprocessed text document: {doc.content[:20]!r}...")
159+
self._logger.debug(
160+
f"Reprocessed text document: {doc.content[:20]!r}..."
161+
)
149162
except Exception as e:
150163
self._logger.error(f"Error processing text document {doc.id}: {e}")
151164

@@ -162,36 +175,34 @@ def rebuild(self, remove_missing: bool = False) -> dict:
162175
def reset(self) -> bool:
163176
"""Reset/clear the entire database by deleting and recreating it"""
164177
db_path = self.settings.db_path
165-
178+
166179
try:
167180
# Close the database connection
168181
self._conn.close()
169-
182+
170183
# Delete the database file if it exists
171184
if Path(db_path).exists():
172185
Path(db_path).unlink()
173186
self._logger.info(f"Deleted database file: {db_path}")
174-
187+
175188
# Recreate the database connection and initialize
176-
self._conn = sqlite3.connect(db_path)
177-
Database.initialize(self._conn, self.settings)
178-
189+
self._conn = self._create_db_connection()
190+
179191
# Reinitialize components with new connection
180192
self._repository = Repository(self._conn, self.settings)
181193
self._chunker = Chunker(self._conn, self.settings)
182194
self._engine = Engine(self._conn, self.settings, chunker=self._chunker)
183-
195+
184196
# Reset ready flag so initialization happens on next use
185197
self.ready = False
186-
187-
self._logger.info("Database reset completed successfully")
198+
188199
return True
189-
200+
190201
except Exception as e:
191202
self._logger.error(f"Error during database reset: {e}")
192203
return False
193204

194-
def search(self, query: str, top_k: int = 10) -> list[Document]:
205+
def search(self, query: str, top_k: int = 10) -> list[DocumentResult]:
195206
"""Search for documents matching the query"""
196207
self._ensure_initialized()
197208

0 commit comments

Comments
 (0)