1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 aphrody contributors
//! Incremental filesystem scanner and chunk indexer with BLAKE3 content hashing,
//! .gitignore respect, and batched vector embedding generation.
use std::{path::Path, sync::Arc, time::Instant};
use aphrody_rag_core::{ChunkKind, ChunkOptions, chunker_for_path, detect_language};
use ignore::WalkBuilder;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use crate::{
embedder::Embedder,
error::Result,
store::{NewChunk, NewDoc, RagStore},
};
/// Configuration options for the indexing pass.
#[derive(Debug, Clone)]
pub struct IndexOptions {
/// Chunking strategy options.
pub chunk_opts: ChunkOptions,
/// Batch size for vector embedding computation.
pub batch_size: usize,
/// Force re-indexing even if BLAKE3 content hash matches existing record.
pub force_reindex: bool,
/// Explicit whitelist of file extensions (empty means allow all supported).
pub extensions: Vec<String>,
/// Maximum allowed file size in bytes before skipping (default 2 MiB).
pub max_file_size: u64,
}
impl Default for IndexOptions {
fn default() -> Self {
Self {
chunk_opts: ChunkOptions::default(),
batch_size: 32,
force_reindex: false,
extensions: Vec::new(),
max_file_size: 2 * 1024 * 1024,
}
}
}
/// Aggregated statistics from an indexing run.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IndexStats {
/// Total files encountered in directory walk.
pub scanned_files: usize,
/// Files successfully indexed (new or updated).
pub indexed_files: usize,
/// Files skipped because their hash did not change or size exceeded cap.
pub skipped_files: usize,
/// Total chunks created and stored.
pub total_chunks: usize,
/// Total dense vectors generated and stored.
pub total_vectors: usize,
/// Non-fatal file-level errors encountered.
pub errors: Vec<String>,
/// Duration of indexing run in milliseconds.
pub elapsed_ms: u64,
}
/// Incremental repository and directory indexer.
pub struct RagIndexer {
store: Arc<RagStore>,
embedder: Option<Arc<dyn Embedder>>,
}
impl RagIndexer {
/// Create a new indexer writing to `store`.
pub fn new(store: Arc<RagStore>) -> Self {
Self {
store,
embedder: None,
}
}
/// Attach an embedding model for automatic vector generation during indexing.
pub fn with_embedder(mut self, embedder: Arc<dyn Embedder>) -> Self {
self.embedder = Some(embedder);
self
}
/// Recursively index a directory tree starting at `root_dir`.
pub async fn index_directory(
&self,
root_dir: impl AsRef<Path>,
opts: &IndexOptions,
) -> Result<IndexStats> {
let root = root_dir.as_ref();
let start = Instant::now();
if let Some(embedder) = &self.embedder {
self.store
.ensure_embedding_model(embedder.model_id())
.await?;
}
let mut stats = IndexStats::default();
let walker = WalkBuilder::new(root)
.hidden(false) // allows scanning .claude, etc. if needed, but gitignore still filters
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.build();
for entry_res in walker {
let entry = match entry_res {
Ok(e) => e,
Err(err) => {
stats.errors.push(format!("Walk error: {err}"));
continue;
}
};
let path = entry.path();
if !path.is_file() {
continue;
}
stats.scanned_files += 1;
// Check extensions filter if provided
if !opts.extensions.is_empty() {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if !opts.extensions.iter().any(|e| e.eq_ignore_ascii_case(&ext)) {
stats.skipped_files += 1;
continue;
}
}
match self.index_file(path, root, opts).await {
Ok(Some((chunks_count, vectors_count))) => {
stats.indexed_files += 1;
stats.total_chunks += chunks_count;
stats.total_vectors += vectors_count;
}
Ok(None) => {
stats.skipped_files += 1;
}
Err(err) => {
stats
.errors
.push(format!("Failed to index {}: {err}", path.display()));
}
}
}
if stats.indexed_files > 0 {
// Bulk load finished: fold the WAL back into the database file.
if let Err(error) = self.store.checkpoint().await {
tracing::debug!(%error, "post-index WAL checkpoint failed");
}
}
stats.elapsed_ms = start.elapsed().as_millis() as u64;
Ok(stats)
}
/// Index a single file relative to `base_dir`. Returns `Some((chunks, vectors))` if indexed,
/// or `None` if skipped.
pub async fn index_file(
&self,
file_path: impl AsRef<Path>,
base_dir: impl AsRef<Path>,
opts: &IndexOptions,
) -> Result<Option<(usize, usize)>> {
let abs_path = file_path.as_ref();
let base = base_dir.as_ref();
let rel_path_str = abs_path
.strip_prefix(base)
.unwrap_or(abs_path)
.to_string_lossy()
.replace('\\', "/");
let metadata = match tokio::fs::metadata(abs_path).await {
Ok(m) => m,
Err(err) => {
debug!("Cannot read metadata for {}: {err}", abs_path.display());
return Ok(None);
}
};
if metadata.len() > opts.max_file_size {
debug!(
"Skipping {}: size {} exceeds max {}",
abs_path.display(),
metadata.len(),
opts.max_file_size
);
return Ok(None);
}
let bytes = match tokio::fs::read(abs_path).await {
Ok(b) => b,
Err(err) => {
warn!("Cannot read file {}: {err}", abs_path.display());
return Ok(None);
}
};
// Text verification: skip binary files
let content_str = match std::str::from_utf8(&bytes) {
Ok(s) => s,
Err(_) => {
debug!("Skipping non-UTF-8 binary file {}", abs_path.display());
return Ok(None);
}
};
let mtime_ms = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
self.index_document(
&rel_path_str,
content_str,
metadata.len() as i64,
mtime_ms,
opts,
)
.await
}
/// Index one in-memory document under a logical key.
///
/// This is the path used by non-filesystem sources — notably content
/// scraped by BXC, where `path_key` is the source URL. Chunking, hashing,
/// chunk replacement and embedding are identical to [`Self::index_file`],
/// which delegates here once it has read the file off disk.
///
/// Returns `Some((chunks, vectors))`, or `None` when an identical document
/// is already indexed and `force_reindex` is not set.
///
/// # Errors
///
/// Propagates store failures (upsert, chunk insertion, vector insertion).
pub async fn index_document(
&self,
path_key: &str,
content_str: &str,
size_bytes: i64,
mtime_ms: i64,
opts: &IndexOptions,
) -> Result<Option<(usize, usize)>> {
let rel_path_str = path_key.to_owned();
let content_hash = blake3::hash(content_str.as_bytes()).to_hex().to_string();
if !opts.force_reindex
&& let Some(doc) = self.store.get_doc_by_path(&rel_path_str).await?
&& doc.hash == content_hash
{
return Ok(None);
}
let lang = format!("{:?}", detect_language(&rel_path_str)).to_ascii_lowercase();
// Prepare chunks before replacing anything. The shared store commits the
// document metadata and all replacement chunks in one transaction.
let chunks = {
let chunker = chunker_for_path(&rel_path_str);
chunker.chunk(&rel_path_str, content_str, &opts.chunk_opts)
};
let chunks: Vec<NewChunk> = chunks
.iter()
.map(|ch| NewChunk {
heading: ch
.symbol
.clone()
.filter(|_| matches!(ch.kind, ChunkKind::Heading)),
symbol: ch
.symbol
.clone()
.filter(|_| matches!(ch.kind, ChunkKind::Code)),
content: ch.text.clone(),
start_line: ch.start_line as usize,
end_line: ch.end_line as usize,
token_count: (ch.text.len() / 4).max(1),
})
.collect();
let inserted_chunk_ids = self
.store
.upsert_doc_chunks(
NewDoc {
path: &rel_path_str,
hash: &content_hash,
title: None,
language: Some(&lang),
mtime_ms,
size_bytes,
},
&chunks,
)
.await?;
if inserted_chunk_ids.is_empty() {
return Ok(Some((0, 0)));
}
let chunk_texts: Vec<String> = chunks.into_iter().map(|chunk| chunk.content).collect();
// Generate vector embeddings if embedder configured
let mut vector_count = 0;
if let Some(ref emb) = self.embedder {
let model_id = emb.model_id().to_string();
// Process embeddings in batches
for (chunk_ids_batch, texts_batch) in inserted_chunk_ids
.chunks(opts.batch_size)
.zip(chunk_texts.chunks(opts.batch_size))
{
if let Ok(embeddings) = emb.embed_batch(texts_batch).await {
let items: Vec<(i64, Vec<f32>)> =
chunk_ids_batch.iter().copied().zip(embeddings).collect();
if let Ok(written) = self.store.insert_vectors(&model_id, &items).await {
vector_count += written;
}
}
}
}
Ok(Some((inserted_chunk_ids.len(), vector_count)))
}
}