JackSparrow89 commited on
Commit
608a156
Β·
verified Β·
1 Parent(s): 775b78b

Update indexer/store.py

Browse files
Files changed (1) hide show
  1. indexer/store.py +241 -238
indexer/store.py CHANGED
@@ -1,238 +1,241 @@
1
- # indexer/store.py
2
-
3
- import os
4
- import sqlite3
5
- import numpy as np
6
- import faiss
7
- import yaml
8
-
9
-
10
- class Store:
11
- """
12
- Handles two storage systems:
13
-
14
- 1. FAISS β€” stores dense vectors for fast similarity search
15
- Uses IndexHNSWFlat instead of IndexFlatL2
16
- HNSW = Hierarchical Navigable Small World graph
17
- - IndexFlatL2 : scans every vector (slow at scale)
18
- - IndexHNSWFlat: graph-based navigation (fast, same accuracy)
19
-
20
- 2. SQLite β€” stores metadata about each chunk
21
- """
22
-
23
- # HNSW parameter β€” higher = more accurate but more memory
24
- # 32 is the standard default, good balance for this use case
25
- HNSW_M = 32
26
-
27
- def __init__(self, config_path="config.yaml"):
28
- """
29
- Load config, set up file paths, initialize FAISS index and SQLite.
30
- """
31
- with open(config_path, "r") as f:
32
- config = yaml.safe_load(f)
33
-
34
- self.data_dir = config["data_dir"]
35
- os.makedirs(self.data_dir, exist_ok=True)
36
-
37
- self.faiss_path = os.path.join(self.data_dir, "index.faiss")
38
- self.db_path = os.path.join(self.data_dir, "metadata.db")
39
-
40
- self._init_db()
41
- self._load_or_create_index()
42
-
43
- def _init_db(self):
44
- """
45
- Create SQLite tables if they don't already exist.
46
- """
47
- conn = sqlite3.connect(self.db_path)
48
- cursor = conn.cursor()
49
-
50
- cursor.execute('''
51
- CREATE TABLE IF NOT EXISTS chunks (
52
- id INTEGER PRIMARY KEY,
53
- filepath TEXT NOT NULL,
54
- chunk_text TEXT NOT NULL,
55
- chunk_index INTEGER,
56
- FOREIGN KEY (filepath) REFERENCES files(filepath)
57
- )
58
- ''')
59
-
60
- cursor.execute('''
61
- CREATE TABLE IF NOT EXISTS files (
62
- filepath TEXT PRIMARY KEY,
63
- file_hash TEXT NOT NULL,
64
- total_chunks INTEGER
65
- )
66
- ''')
67
-
68
- conn.commit()
69
- conn.close()
70
-
71
- def _load_or_create_index(self):
72
- """
73
- Load an existing FAISS index from disk, or set to None.
74
- The actual index is created on first add_chunks() call
75
- so we know the embedding dimension at that point.
76
- """
77
- if os.path.exists(self.faiss_path):
78
- self.index = faiss.read_index(self.faiss_path)
79
- print(f"[Store] Loaded FAISS index β€” {self.index.ntotal} vectors")
80
- else:
81
- self.index = None
82
- print("[Store] No existing index found β€” will create on first insert")
83
-
84
- def _create_hnsw_index(self, dimension: int):
85
- """
86
- Create a new HNSW-based FAISS index.
87
-
88
- Why HNSW over FlatL2:
89
- FlatL2 β€” exact search, O(n) per query, slow at scale
90
- HNSWFlat β€” approximate search, O(log n) per query, same accuracy
91
- for top-k retrieval tasks
92
-
93
- IndexIDMap2 wraps HNSW to support custom integer IDs and deletion.
94
-
95
- Args:
96
- dimension β€” embedding size (384 for MiniLM and BGE-small)
97
- """
98
- hnsw_index = faiss.IndexHNSWFlat(dimension, self.HNSW_M)
99
- hnsw_index.hnsw.efSearch = 64 # search quality β€” higher = better recall
100
- hnsw_index.hnsw.efConstruction = 64 # build quality β€” higher = better graph
101
- self.index = faiss.IndexIDMap2(hnsw_index)
102
- print(f"[Store] Created HNSW index β€” dim={dimension}, M={self.HNSW_M}")
103
-
104
- def get_next_id(self):
105
- """
106
- Get the next available chunk ID from SQLite.
107
- """
108
- conn = sqlite3.connect(self.db_path)
109
- cursor = conn.cursor()
110
- cursor.execute("SELECT MAX(id) FROM chunks")
111
- result = cursor.fetchone()[0]
112
- conn.close()
113
- return 0 if result is None else result + 1
114
-
115
- def add_chunks(self, chunks_with_metadata, embeddings):
116
- """
117
- Add new chunks and their embeddings to both FAISS and SQLite.
118
-
119
- Args:
120
- chunks_with_metadata (list[dict]) β€” from chunker.chunk_file()
121
- Each dict has: text, filepath, chunk_index
122
- embeddings (numpy.ndarray) β€” shape (num_chunks, embedding_dim)
123
- From embedder.embed_chunks()
124
- """
125
- embeddings = embeddings.astype("float32")
126
-
127
- # create index on first insert β€” dimension comes from embeddings
128
- if self.index is None:
129
- dimension = embeddings.shape[1]
130
- self._create_hnsw_index(dimension)
131
-
132
- start_id = self.get_next_id()
133
- ids = np.array(
134
- [start_id + i for i in range(len(chunks_with_metadata))],
135
- dtype=np.int64
136
- )
137
-
138
- self.index.add_with_ids(embeddings, ids)
139
- faiss.write_index(self.index, self.faiss_path)
140
-
141
- # save chunk metadata to SQLite
142
- conn = sqlite3.connect(self.db_path)
143
- cursor = conn.cursor()
144
-
145
- for i, chunk in enumerate(chunks_with_metadata):
146
- vector_id = start_id + i
147
- cursor.execute(
148
- "INSERT INTO chunks (id, filepath, chunk_text, chunk_index) "
149
- "VALUES (?, ?, ?, ?)",
150
- (vector_id, chunk["filepath"], chunk["text"], chunk["chunk_index"])
151
- )
152
-
153
- conn.commit()
154
- conn.close()
155
-
156
- def save_file_info(self, filepath, file_hash, total_chunks):
157
- """
158
- Save or update file info in SQLite.
159
-
160
- Args:
161
- filepath β€” file path or fake path e.g. "scifact://12345"
162
- file_hash β€” SHA256 hash or doc_id string
163
- total_chunks β€” number of chunks this file was split into
164
- """
165
- conn = sqlite3.connect(self.db_path)
166
- cursor = conn.cursor()
167
- cursor.execute(
168
- "INSERT OR REPLACE INTO files (filepath, file_hash, total_chunks) "
169
- "VALUES (?, ?, ?)",
170
- (filepath, file_hash, total_chunks)
171
- )
172
- conn.commit()
173
- conn.close()
174
-
175
- def load_hashes(self):
176
- """
177
- Load all stored file hashes from SQLite.
178
-
179
- Returns:
180
- dict β€” {filepath: hash_string}
181
- """
182
- conn = sqlite3.connect(self.db_path)
183
- cursor = conn.cursor()
184
- cursor.execute("SELECT filepath, file_hash FROM files")
185
- rows = cursor.fetchall()
186
- conn.close()
187
- return {row[0]: row[1] for row in rows}
188
-
189
- def remove_file_chunks(self, filepath):
190
- """
191
- Delete all chunks for a file from both SQLite and FAISS.
192
-
193
- Args:
194
- filepath β€” the filepath to remove
195
- """
196
- conn = sqlite3.connect(self.db_path)
197
- cursor = conn.cursor()
198
-
199
- ids = cursor.execute(
200
- "SELECT id FROM chunks WHERE filepath = ?", (filepath,)
201
- ).fetchall()
202
-
203
- cursor.execute("DELETE FROM chunks WHERE filepath = ?", (filepath,))
204
- cursor.execute("DELETE FROM files WHERE filepath = ?", (filepath,))
205
- conn.commit()
206
- conn.close()
207
-
208
- if ids and self.index is not None:
209
- id_array = np.array([i[0] for i in ids], dtype=np.int64)
210
- self.index.remove_ids(id_array)
211
- faiss.write_index(self.index, self.faiss_path)
212
-
213
- def get_total_vectors(self):
214
- """
215
- Return how many vectors are in the FAISS index.
216
-
217
- Returns:
218
- int β€” number of vectors, or 0 if index is empty
219
- """
220
- if self.index is None:
221
- return 0
222
- return self.index.ntotal
223
-
224
-
225
- if __name__ == "__main__":
226
- store = Store()
227
-
228
- fake_chunks = [
229
- {"text": "quarterly budget report summary", "filepath": "/docs/report.pdf", "chunk_index": 0},
230
- {"text": "revenue increased by fifteen percent", "filepath": "/docs/report.pdf", "chunk_index": 1},
231
- {"text": "python machine learning tutorial", "filepath": "/docs/tutorial.txt", "chunk_index": 0},
232
- ]
233
-
234
- fake_embeddings = np.random.rand(3, 384).astype("float32")
235
-
236
- print(f"Vectors before: {store.get_total_vectors()}")
237
- store.add_chunks(fake_chunks, fake_embeddings)
238
- print(f"Vectors after: {store.get_total_vectors()}")
 
 
 
 
1
+ # indexer/store.py
2
+
3
+ import os
4
+ import sqlite3
5
+ import numpy as np
6
+ import faiss
7
+ import yaml
8
+
9
+
10
+ class Store:
11
+ """
12
+ Handles two storage systems:
13
+
14
+ 1. FAISS β€” stores dense vectors for fast similarity search
15
+ Uses IndexHNSWFlat instead of IndexFlatL2
16
+ HNSW = Hierarchical Navigable Small World graph
17
+ - IndexFlatL2 : scans every vector (slow at scale)
18
+ - IndexHNSWFlat: graph-based navigation (fast, same accuracy)
19
+
20
+ 2. SQLite β€” stores metadata about each chunk
21
+ """
22
+
23
+ # HNSW parameter β€” higher = more accurate but more memory
24
+ # 32 is the standard default, good balance for this use case
25
+ HNSW_M = 32
26
+
27
+ def __init__(self, config_path="config.yaml"):
28
+ """
29
+ Load config, set up file paths, initialize FAISS index and SQLite.
30
+ """
31
+ config_path = os.path.abspath(config_path)
32
+ with open(config_path, "r") as f:
33
+ config = yaml.safe_load(f)
34
+
35
+ config_dir = os.path.dirname(config_path)
36
+ data_dir = config["data_dir"]
37
+ self.data_dir = data_dir if os.path.isabs(data_dir) else os.path.normpath(os.path.join(config_dir, data_dir))
38
+ os.makedirs(self.data_dir, exist_ok=True)
39
+
40
+ self.faiss_path = os.path.join(self.data_dir, "index.faiss")
41
+ self.db_path = os.path.join(self.data_dir, "metadata.db")
42
+
43
+ self._init_db()
44
+ self._load_or_create_index()
45
+
46
+ def _init_db(self):
47
+ """
48
+ Create SQLite tables if they don't already exist.
49
+ """
50
+ conn = sqlite3.connect(self.db_path)
51
+ cursor = conn.cursor()
52
+
53
+ cursor.execute('''
54
+ CREATE TABLE IF NOT EXISTS chunks (
55
+ id INTEGER PRIMARY KEY,
56
+ filepath TEXT NOT NULL,
57
+ chunk_text TEXT NOT NULL,
58
+ chunk_index INTEGER,
59
+ FOREIGN KEY (filepath) REFERENCES files(filepath)
60
+ )
61
+ ''')
62
+
63
+ cursor.execute('''
64
+ CREATE TABLE IF NOT EXISTS files (
65
+ filepath TEXT PRIMARY KEY,
66
+ file_hash TEXT NOT NULL,
67
+ total_chunks INTEGER
68
+ )
69
+ ''')
70
+
71
+ conn.commit()
72
+ conn.close()
73
+
74
+ def _load_or_create_index(self):
75
+ """
76
+ Load an existing FAISS index from disk, or set to None.
77
+ The actual index is created on first add_chunks() call
78
+ so we know the embedding dimension at that point.
79
+ """
80
+ if os.path.exists(self.faiss_path):
81
+ self.index = faiss.read_index(self.faiss_path)
82
+ print(f"[Store] Loaded FAISS index β€” {self.index.ntotal} vectors")
83
+ else:
84
+ self.index = None
85
+ print("[Store] No existing index found β€” will create on first insert")
86
+
87
+ def _create_hnsw_index(self, dimension: int):
88
+ """
89
+ Create a new HNSW-based FAISS index.
90
+
91
+ Why HNSW over FlatL2:
92
+ FlatL2 β€” exact search, O(n) per query, slow at scale
93
+ HNSWFlat β€” approximate search, O(log n) per query, same accuracy
94
+ for top-k retrieval tasks
95
+
96
+ IndexIDMap2 wraps HNSW to support custom integer IDs and deletion.
97
+
98
+ Args:
99
+ dimension β€” embedding size (384 for MiniLM and BGE-small)
100
+ """
101
+ hnsw_index = faiss.IndexHNSWFlat(dimension, self.HNSW_M)
102
+ hnsw_index.hnsw.efSearch = 64 # search quality β€” higher = better recall
103
+ hnsw_index.hnsw.efConstruction = 64 # build quality β€” higher = better graph
104
+ self.index = faiss.IndexIDMap2(hnsw_index)
105
+ print(f"[Store] Created HNSW index β€” dim={dimension}, M={self.HNSW_M}")
106
+
107
+ def get_next_id(self):
108
+ """
109
+ Get the next available chunk ID from SQLite.
110
+ """
111
+ conn = sqlite3.connect(self.db_path)
112
+ cursor = conn.cursor()
113
+ cursor.execute("SELECT MAX(id) FROM chunks")
114
+ result = cursor.fetchone()[0]
115
+ conn.close()
116
+ return 0 if result is None else result + 1
117
+
118
+ def add_chunks(self, chunks_with_metadata, embeddings):
119
+ """
120
+ Add new chunks and their embeddings to both FAISS and SQLite.
121
+
122
+ Args:
123
+ chunks_with_metadata (list[dict]) β€” from chunker.chunk_file()
124
+ Each dict has: text, filepath, chunk_index
125
+ embeddings (numpy.ndarray) β€” shape (num_chunks, embedding_dim)
126
+ From embedder.embed_chunks()
127
+ """
128
+ embeddings = embeddings.astype("float32")
129
+
130
+ # create index on first insert β€” dimension comes from embeddings
131
+ if self.index is None:
132
+ dimension = embeddings.shape[1]
133
+ self._create_hnsw_index(dimension)
134
+
135
+ start_id = self.get_next_id()
136
+ ids = np.array(
137
+ [start_id + i for i in range(len(chunks_with_metadata))],
138
+ dtype=np.int64
139
+ )
140
+
141
+ self.index.add_with_ids(embeddings, ids)
142
+ faiss.write_index(self.index, self.faiss_path)
143
+
144
+ # save chunk metadata to SQLite
145
+ conn = sqlite3.connect(self.db_path)
146
+ cursor = conn.cursor()
147
+
148
+ for i, chunk in enumerate(chunks_with_metadata):
149
+ vector_id = start_id + i
150
+ cursor.execute(
151
+ "INSERT INTO chunks (id, filepath, chunk_text, chunk_index) "
152
+ "VALUES (?, ?, ?, ?)",
153
+ (vector_id, chunk["filepath"], chunk["text"], chunk["chunk_index"])
154
+ )
155
+
156
+ conn.commit()
157
+ conn.close()
158
+
159
+ def save_file_info(self, filepath, file_hash, total_chunks):
160
+ """
161
+ Save or update file info in SQLite.
162
+
163
+ Args:
164
+ filepath β€” file path or fake path e.g. "scifact://12345"
165
+ file_hash β€” SHA256 hash or doc_id string
166
+ total_chunks β€” number of chunks this file was split into
167
+ """
168
+ conn = sqlite3.connect(self.db_path)
169
+ cursor = conn.cursor()
170
+ cursor.execute(
171
+ "INSERT OR REPLACE INTO files (filepath, file_hash, total_chunks) "
172
+ "VALUES (?, ?, ?)",
173
+ (filepath, file_hash, total_chunks)
174
+ )
175
+ conn.commit()
176
+ conn.close()
177
+
178
+ def load_hashes(self):
179
+ """
180
+ Load all stored file hashes from SQLite.
181
+
182
+ Returns:
183
+ dict β€” {filepath: hash_string}
184
+ """
185
+ conn = sqlite3.connect(self.db_path)
186
+ cursor = conn.cursor()
187
+ cursor.execute("SELECT filepath, file_hash FROM files")
188
+ rows = cursor.fetchall()
189
+ conn.close()
190
+ return {row[0]: row[1] for row in rows}
191
+
192
+ def remove_file_chunks(self, filepath):
193
+ """
194
+ Delete all chunks for a file from both SQLite and FAISS.
195
+
196
+ Args:
197
+ filepath β€” the filepath to remove
198
+ """
199
+ conn = sqlite3.connect(self.db_path)
200
+ cursor = conn.cursor()
201
+
202
+ ids = cursor.execute(
203
+ "SELECT id FROM chunks WHERE filepath = ?", (filepath,)
204
+ ).fetchall()
205
+
206
+ cursor.execute("DELETE FROM chunks WHERE filepath = ?", (filepath,))
207
+ cursor.execute("DELETE FROM files WHERE filepath = ?", (filepath,))
208
+ conn.commit()
209
+ conn.close()
210
+
211
+ if ids and self.index is not None:
212
+ id_array = np.array([i[0] for i in ids], dtype=np.int64)
213
+ self.index.remove_ids(id_array)
214
+ faiss.write_index(self.index, self.faiss_path)
215
+
216
+ def get_total_vectors(self):
217
+ """
218
+ Return how many vectors are in the FAISS index.
219
+
220
+ Returns:
221
+ int β€” number of vectors, or 0 if index is empty
222
+ """
223
+ if self.index is None:
224
+ return 0
225
+ return self.index.ntotal
226
+
227
+
228
+ if __name__ == "__main__":
229
+ store = Store()
230
+
231
+ fake_chunks = [
232
+ {"text": "quarterly budget report summary", "filepath": "/docs/report.pdf", "chunk_index": 0},
233
+ {"text": "revenue increased by fifteen percent", "filepath": "/docs/report.pdf", "chunk_index": 1},
234
+ {"text": "python machine learning tutorial", "filepath": "/docs/tutorial.txt", "chunk_index": 0},
235
+ ]
236
+
237
+ fake_embeddings = np.random.rand(3, 384).astype("float32")
238
+
239
+ print(f"Vectors before: {store.get_total_vectors()}")
240
+ store.add_chunks(fake_chunks, fake_embeddings)
241
+ print(f"Vectors after: {store.get_total_vectors()}")