perf: index filename candidates and avoid full-corpus retrieval scans
Core CI / core (ubuntu-latest) (push) Failing after 1m18s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 3m34s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s

This commit is contained in:
karl committed 2026-10-12 16:35:46 +02:00
1 parent a9b6592330
commit 55fa751571
11 files changed
+1030 -64

No files matched your search

+34 -5
View File
@@ -4,7 +4,7 @@ The core has no desktop or inference dependencies. Its current retrieval engine
combines literal filename matching with SQLite FTS5 BM25 keyword search. It does combines literal filename matching with SQLite FTS5 BM25 keyword search. It does
not implement semantic search. not implement semantic search.
`SearchIndex::open` creates or migrates schema version 4. Current-schema opens do `SearchIndex::open` creates or migrates schema version 7. Current-schema opens do
not acquire a write lock. WAL allows independent readers to use the committed not acquire a write lock. WAL allows independent readers to use the committed
index while reconciliation runs. All indexing changes are committed atomically; index while reconciliation runs. All indexing changes are committed atomically;
a traversal or plain-text file read error rolls back the entire reconciliation. a traversal or plain-text file read error rolls back the entire reconciliation.
@@ -73,10 +73,39 @@ apply before limiting results. `search` retains the unfiltered API. Query text i
escaped as literal FTS terms, not interpreted as operators. Results are capped at escaped as literal FTS terms, not interpreted as operators. Results are capped at
1,000 and queries at 4,096 bytes. 1,000 and queries at 4,096 bytes.
Retrieval runs complete SQL-filtered lanes: indexed exact filename equality,
literal filename substring, literal path substring, and content-only BM25.
Multiword queries also have a tokenized name/path fallback, so `annual report`
can find `annual_report.md`. Single-token filename identifiers are matched
literally; the text tokenizer still handles punctuation inside document content.
Exact filenames always precede other lanes. Ties use persistent file row IDs,
and keyword ties use relevance then row ID. Selected rows are deduplicated.
Filename/path substrings of at least three Unicode characters use an
external-content trigram FTS index. The rarest known query trigram supplies a
candidate superset; the whole literal substring is verified on the candidate.
At most 256 unique query trigrams are inspected. This lookup budget does not cap
candidate rows or hide matching results. All size/type/folder/date filters apply
before each lane's SQL LIMIT. Shorter substrings retain complete metadata scans
and can be slow. Broad queries under restrictive filters can also examine many
matching candidates; no universal filtered-query latency claim is made.
Trigram frequencies use the FTS vocabulary and a bounded 4,096-term cache on each
`SearchIndex`. Initial frequency lookups can be expensive; the cache is cleared
after reconciliation. Keep an independent reader engine alive across searches to
benefit from warm behavior. These frequency estimates affect candidate selection
cost, not correctness. A source query batches all finalists' indexed passage IDs
and selects actual matching passages before choosing one source per file.
Migration to the new indexes preserves file content and passage IDs and rebuilds
both filename/path and passage indexes atomically. Large migrations can take time
and increase database size; they must not be confused with normal current-schema
opens. The measured Windows synthetic fixture grew from about 295 MiB to 605 MiB.
## Known limits ## Known limits
- Filename substring lookup uses `instr` and scans metadata. A dedicated substring - Short substring queries and broad restrictive filters can still scan many
index and benchmarks are needed before claiming million-file latency targets. metadata candidates. Cold frequency-cache fills exceed the warm filename target.
- Files changed without changing size or modification time require a future - Files changed without changing size or modification time require a future
forced refresh/content-fingerprint option. Timestamp precision depends on the forced refresh/content-fingerprint option. Timestamp precision depends on the
filesystem. Indexing does not yet run a filesystem watcher. filesystem. Indexing does not yet run a filesystem watcher.
@@ -91,5 +120,5 @@ escaped as literal FTS terms, not interpreted as operators. Results are capped a
Bounded worker isolation, persistent jobs, and crash-resumable scans remain Bounded worker isolation, persistent jobs, and crash-resumable scans remain
production milestones. production milestones.
- OCR, media extraction, and real model inference belong to subsequent modules. - OCR, media extraction, and real model inference belong to subsequent modules.
- Passage selection currently adds a bounded query per result; query batching and - Linux/macOS measurements, realistic mixed-document corpora, cold startup,
passage ranking benchmarks remain necessary for large result lists. memory budgets, and indexing performance remain unverified.
+104
View File
@@ -0,0 +1,104 @@
# CPU retrieval performance
`examples/performance.rs` builds an explicitly synthetic SQLite corpus using the
real core schema and normal FTS triggers. It measures warm core retrieval only;
there is no filesystem scanning, document extraction, model inference, GUI
rendering, or GPU provider in this benchmark.
The default smoke fixture contains 10,000 file records and 1,000 searchable
document/passages. The acceptance fixture contains 1,000,000 file records and
100,000 searchable documents, one paragraph per document. The remaining records
have filenames but no content. Names and documents are synthetic; they do not
represent a realistic corpus distribution, so passing these measurements alone
would not establish production performance.
```powershell
cargo run --release -p searchi-core --example performance -- --files 1000000 --documents 100000 --samples 20 --db target/benchmarks/full.db
```
Use a new database path when seeding. The harness refuses to overwrite an
existing database. To compare engine versions on the same corpus, use `--reuse true`;
this validates file/document counts and synthetic root ownership before opening
and migrating the existing fixture. It records migration time separately and
reports zero seed time. It seeds at most 5,000 records per transaction, checkpoints
the WAL, then performs three warmups and 20 measured queries per case. Percentiles
use the nearest-rank convention; with 20 samples, p95 is the 19th sorted sample.
The first execution of each query is recorded separately before the remaining
two warmups; this is first use of that query on the current engine, not a cold
OS disk-cache test. Timing uses a monotonic clock. Results include committed database size and fixture
seed time, which must not be described as document extraction throughput.
Cases cover an exact filename near the end of the corpus, a broad filename
substring, a unique content token, and a broad content token. All queries use the
public search API with limit 20, including actual source-passage selection. Target
p95 is below 50 ms for filenames and below 150 ms for keyword search. A result
above either target is explicitly marked as a failure. Database file bytes exclude
temporary WAL/SHM sidecars and process memory.
## Reference environment
- Date: 2026-10-12.
- Windows VM: Windows 11 Pro, version 10.0.26300.
- CPU name exposed by VM: AMD Ryzen 7 7800X3D 8-Core Processor.
- VM allocation: 4 logical processors, 10,666,553,344 bytes RAM (about 9.93 GiB).
- Rust release build, bundled SQLite, CPU only.
- Toolchain: rustc 1.99.0 (b940084d7 2026-09-28), cargo 1.99.0.
- Engine baseline: `a9b6592330d88745eadf2a748bb3a65f62830590`, schema 4.
- Storage device and host scheduling contention are not characterized.
Run results are stored in `results/`. Linux and macOS measurements are pending.
No cross-platform acceptance claim can be inferred from the Windows VM results.
## Baseline result
The full acceptance fixture fails all four latency targets. This is a verified
performance gap, not a production-ready retrieval result.
| Query case | p50 | p95 | p95 target | Result |
| --- | ---: | ---: | ---: | --- |
| Exact filename | 404.76 ms | 633.82 ms | <50 ms | Failed |
| Broad filename substring | 3,385.76 ms | 4,131.48 ms | <50 ms | Failed |
| Unique content token | 311.63 ms | 355.78 ms | <150 ms | Failed |
| Broad keyword | 2,186.24 ms | 2,746.91 ms | <150 ms | Failed |
The 1M/100K synthetic fixture took 277.23 seconds to seed and its checkpointed
database occupies 309,338,112 bytes (about 295 MiB). Smaller 10K/1K smoke results
passed the same targets, showing why small-corpus tests must not be used as proof
of scalability. Neither seed time measures document extraction throughput.
The baseline implementation scanned file metadata for substring matching and used
per-result passage lookups. Its measured behavior warranted a dedicated filename
substring index, candidate-first FTS retrieval, and batched passage selection.
The following section records the resulting implementation and measurements.
## Optimized result, schema 7
The unchanged full fixture was migrated and reused. All four **warm** target
measurements pass in this Windows CPU run. This does not establish production
acceptance for all workloads or operating systems.
| Query case | Baseline p95 | Optimized p50 | Optimized p95 | First use | Warm target |
| --- | ---: | ---: | ---: | ---: | --- |
| Exact filename | 633.82 ms | 11.66 ms | 13.17 ms | 279.73 ms | Passed |
| Broad filename substring | 4,131.48 ms | 0.39 ms | 1.68 ms | 3.34 ms | Passed |
| Unique content token | 355.78 ms | 0.28 ms | 0.39 ms | 50.08 ms | Passed |
| Broad keyword | 2,746.91 ms | 97.92 ms | 111.54 ms | 101.35 ms | Passed |
The updated checkpointed database is 634,449,920 bytes (about 605 MiB). The
improvement trades additional indexes and disk space for retrieval latency.
The initial schema-4-to-5 backfill took 19.65 seconds on this fixture, as recorded
in the intermediate report. The final report's 3.84 ms `migration_seconds` is an
already-current-schema open, not a measurement of the initial index backfill.
Indexed equality, trigram candidate retrieval, content-only FTS, and a single
batched source lookup replaced full metadata joins and per-result FTS scans.
Rarest-trigram vocabulary counts are cached in the reader engine. The first-use
exact result exceeds the warm target and is explicitly retained as an outstanding
latency issue; opening a fresh reader for every query discards this cache.
`windows-cpu-full-optimized-v7.json` is the final measurement. Earlier schema 5/6
reports are intermediate experiments and retain failed exact-query measurements.
The final run did not overlap another benchmark process. Dedicated host isolation
is still not established. Query distribution and filters are not represented by
this four-case fixture; restrictive broad filters and sub-three-character searches
remain potentially slow, while correctness tests ensure late matching files are
not lost through arbitrary pre-filter caps.
@@ -0,0 +1,47 @@
{
"fixture": "synthetic SQLite metadata and text; no filesystem extraction or semantic inference",
"files": 1000000,
"documents": 100000,
"seed_seconds": 0.0,
"migration_seconds": 0.0055438,
"fixture_reused": true,
"database_bytes": 634449920,
"database_path": "target/benchmarks/full.db",
"architecture": "x86_64",
"os": "windows",
"cpu_only": true,
"queries": [
{
"query": "invoice_0999999.txt",
"samples": 20,
"p50_ms": 120.81269999999999,
"p95_ms": 137.9349,
"target_p95_ms": 50.0,
"target_met": false
},
{
"query": "invoice_",
"samples": 20,
"p50_ms": 0.5431,
"p95_ms": 0.7148,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "marker0099999",
"samples": 20,
"p50_ms": 0.5455,
"p95_ms": 0.6604,
"target_p95_ms": 150.0,
"target_met": true
},
{
"query": "cancellation",
"samples": 20,
"p50_ms": 98.23469999999999,
"p95_ms": 103.2545,
"target_p95_ms": 150.0,
"target_met": true
}
]
}
@@ -0,0 +1,51 @@
{
"fixture": "synthetic SQLite metadata and text; no filesystem extraction or semantic inference",
"files": 1000000,
"documents": 100000,
"seed_seconds": 0.0,
"migration_seconds": 0.0038446,
"fixture_reused": true,
"database_bytes": 634449920,
"database_path": "target/benchmarks/full.db",
"architecture": "x86_64",
"os": "windows",
"cpu_only": true,
"queries": [
{
"query": "invoice_0999999.txt",
"samples": 20,
"p50_ms": 11.659600000000001,
"first_query_ms": 279.7262,
"p95_ms": 13.167399999999999,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "invoice_",
"samples": 20,
"p50_ms": 0.3923,
"first_query_ms": 3.3355,
"p95_ms": 1.6800000000000002,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "marker0099999",
"samples": 20,
"p50_ms": 0.2847,
"first_query_ms": 50.083000000000006,
"p95_ms": 0.3948,
"target_p95_ms": 150.0,
"target_met": true
},
{
"query": "cancellation",
"samples": 20,
"p50_ms": 97.9169,
"first_query_ms": 101.3489,
"p95_ms": 111.5428,
"target_p95_ms": 150.0,
"target_met": true
}
]
}
@@ -0,0 +1,47 @@
{
"fixture": "synthetic SQLite metadata and text; no filesystem extraction or semantic inference",
"files": 1000000,
"documents": 100000,
"seed_seconds": 0.0,
"migration_seconds": 19.651645,
"fixture_reused": true,
"database_bytes": 633917440,
"database_path": "target/benchmarks/full.db",
"architecture": "x86_64",
"os": "windows",
"cpu_only": true,
"queries": [
{
"query": "invoice_0999999.txt",
"samples": 20,
"p50_ms": 115.7827,
"p95_ms": 127.39,
"target_p95_ms": 50.0,
"target_met": false
},
{
"query": "invoice_",
"samples": 20,
"p50_ms": 0.4833,
"p95_ms": 1.734,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "marker0099999",
"samples": 20,
"p50_ms": 0.3963,
"p95_ms": 0.5996,
"target_p95_ms": 150.0,
"target_met": true
},
{
"query": "cancellation",
"samples": 20,
"p50_ms": 105.1206,
"p95_ms": 112.6383,
"target_p95_ms": 150.0,
"target_met": true
}
]
}
@@ -0,0 +1,45 @@
{
"fixture": "synthetic SQLite metadata and text; no filesystem extraction or semantic inference",
"files": 1000000,
"documents": 100000,
"seed_seconds": 277.2314138,
"database_bytes": 309338112,
"database_path": "target/benchmarks/full.db",
"architecture": "x86_64",
"os": "windows",
"cpu_only": true,
"queries": [
{
"query": "invoice_0999999.txt",
"samples": 20,
"p50_ms": 404.75899999999996,
"p95_ms": 633.8166,
"target_p95_ms": 50.0,
"target_met": false
},
{
"query": "invoice_",
"samples": 20,
"p50_ms": 3385.7561,
"p95_ms": 4131.4819,
"target_p95_ms": 50.0,
"target_met": false
},
{
"query": "marker0099999",
"samples": 20,
"p50_ms": 311.6295,
"p95_ms": 355.7767,
"target_p95_ms": 150.0,
"target_met": false
},
{
"query": "cancellation",
"samples": 20,
"p50_ms": 2186.2423,
"p95_ms": 2746.9136,
"target_p95_ms": 150.0,
"target_met": false
}
]
}
@@ -0,0 +1,47 @@
{
"fixture": "synthetic SQLite metadata and text; no filesystem extraction or semantic inference",
"files": 10000,
"documents": 1000,
"seed_seconds": 0.0,
"migration_seconds": 0.2795255,
"fixture_reused": true,
"database_bytes": 6320128,
"database_path": "target/benchmarks/small.db",
"architecture": "x86_64",
"os": "windows",
"cpu_only": true,
"queries": [
{
"query": "invoice_0009999.txt",
"samples": 20,
"p50_ms": 1.9414,
"p95_ms": 2.3285,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "invoice_",
"samples": 20,
"p50_ms": 0.3281,
"p95_ms": 1.2427,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "marker0000999",
"samples": 20,
"p50_ms": 0.2561,
"p95_ms": 0.40259999999999996,
"target_p95_ms": 150.0,
"target_met": true
},
{
"query": "cancellation",
"samples": 20,
"p50_ms": 1.2418,
"p95_ms": 1.7393,
"target_p95_ms": 150.0,
"target_met": true
}
]
}
@@ -0,0 +1,45 @@
{
"fixture": "synthetic SQLite metadata and text; no filesystem extraction or semantic inference",
"files": 10000,
"documents": 1000,
"seed_seconds": 0.3428201,
"database_bytes": 3207168,
"database_path": "target/benchmarks/small.db",
"architecture": "x86_64",
"os": "windows",
"cpu_only": true,
"queries": [
{
"query": "invoice_0009999.txt",
"samples": 20,
"p50_ms": 3.0624,
"p95_ms": 3.8085,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "invoice_",
"samples": 20,
"p50_ms": 20.8847,
"p95_ms": 28.4805,
"target_p95_ms": 50.0,
"target_met": true
},
{
"query": "marker0000999",
"samples": 20,
"p50_ms": 1.7667,
"p95_ms": 2.7321,
"target_p95_ms": 150.0,
"target_met": true
},
{
"query": "cancellation",
"samples": 20,
"p50_ms": 12.2173,
"p95_ms": 16.5439,
"target_p95_ms": 150.0,
"target_met": true
}
]
}
+200
View File
@@ -0,0 +1,200 @@
//! CPU-only synthetic retrieval benchmark. This does not measure filesystem
//! scanning, extraction, embedding inference, or desktop rendering performance.
use anyhow::{bail, Context, Result};
use rusqlite::{params, Connection};
use searchi_core::SearchIndex;
use serde::Serialize;
use std::{env, fs, path::PathBuf, time::Instant};
#[derive(Serialize)]
struct Measurements {
query: String,
samples: usize,
p50_ms: f64,
first_query_ms: f64,
p95_ms: f64,
target_p95_ms: f64,
target_met: bool,
}
#[derive(Serialize)]
struct Report {
fixture: &'static str,
files: usize,
documents: usize,
seed_seconds: f64,
migration_seconds: f64,
fixture_reused: bool,
database_bytes: u64,
database_path: String,
architecture: &'static str,
os: &'static str,
cpu_only: bool,
queries: Vec<Measurements>,
}
fn main() -> Result<()> {
let arguments = env::args().skip(1).collect::<Vec<_>>();
let mut files = 10_000usize;
let mut documents = 1_000usize;
let mut samples = 20usize;
let mut database = PathBuf::from("target/benchmarks/synthetic.db");
let mut reuse = false;
for pair in arguments.chunks(2) {
if pair.len() != 2 {
bail!("usage: performance --files N --documents N --samples N --db NEW_PATH");
}
match pair[0].as_str() {
"--files" => files = pair[1].parse()?,
"--documents" => documents = pair[1].parse()?,
"--samples" => samples = pair[1].parse()?,
"--db" => database = PathBuf::from(&pair[1]),
"--reuse" => reuse = pair[1].parse()?,
option => bail!("unknown argument {option}"),
}
}
if files == 0
|| files > 2_000_000
|| documents == 0
|| documents > files
|| !(5..=1000).contains(&samples)
{
bail!("files must be1..2000000, documents1..files, samples5..1000");
}
if database.exists() && !reuse {
bail!(
"benchmark database must be a new path: {}",
database.display()
);
}
if reuse && !database.exists() {
bail!("reuse requires an existing synthetic fixture");
}
if let Some(parent) = database.parent() {
fs::create_dir_all(parent)?;
}
// Seed through the actual versioned schema and its normal indexing triggers.
if reuse {
let connection = Connection::open(&database)?;
let (actual_files, actual_documents, foreign_roots): (usize,usize,usize) = connection.query_row(
"SELECT count(*),count(NULLIF(content,'')),sum(root!='synthetic://benchmark') FROM files",[],
|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?)))?;
if (actual_files, actual_documents, foreign_roots) != (files, documents, 0) {
bail!("existing fixture does not match configured synthetic corpus");
}
}
let migration_started = Instant::now();
let index = SearchIndex::open(&database)?;
drop(index);
let migration_seconds = migration_started.elapsed().as_secs_f64();
let start = Instant::now();
let mut connection = Connection::open(&database)?;
connection.execute_batch("PRAGMA synchronous=NORMAL;")?;
if !reuse {
for batch_start in (0..files).step_by(5000) {
let transaction = connection.transaction()?;
{
let mut statement = transaction.prepare(
"INSERT INTO files(path,root,filename,content,size,mtime,extension)
VALUES (?1,'synthetic://benchmark',?2,?3,?4,0,'txt')",
)?;
let mut passage = transaction.prepare(
"INSERT INTO passages(text,file_path,kind,number) VALUES (?1,?2,'paragraph',1)",
)?;
for number in batch_start..(batch_start + 5000).min(files) {
let filename = format!("invoice_{number:07}.txt");
let path = format!(
"synthetic://benchmark/folder_{:04}/{filename}",
number % 1000
);
let content = if number < documents {
format!("Supplier termination cancellation clause budget analysis marker{number:07}")
} else {
String::new()
};
statement.execute(params![path, filename, content, content.len() as i64])?;
if number < documents {
passage.execute(params![content, path])?;
}
}
}
transaction.commit()?;
if batch_start % 100_000 == 0 {
eprintln!(
"seeded {} / {files} synthetic file records",
(batch_start + 5000).min(files)
);
}
}
}
connection.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
drop(connection);
let seed_seconds = if reuse {
0.0
} else {
start.elapsed().as_secs_f64()
};
let database_bytes = fs::metadata(&database)?.len();
let index = SearchIndex::open(&database)?;
let exact = format!("invoice_{:07}.txt", files - 1);
let unique_content = format!("marker{:07}", documents - 1);
let cases = [
(exact, 50.0),
("invoice_".into(), 50.0),
(unique_content, 150.0),
("cancellation".into(), 150.0),
];
let mut queries = Vec::new();
for (query, target) in cases {
let first_started = Instant::now();
index.search(&query, 20)?;
let first_query_ms = first_started.elapsed().as_secs_f64() * 1000.0;
for _ in 0..2 {
index.search(&query, 20)?;
}
let mut durations = Vec::with_capacity(samples);
for _ in 0..samples {
let started = Instant::now();
let results = index
.search(&query, 20)
.with_context(|| format!("benchmark query {query}"))?;
if results.is_empty() {
bail!("synthetic query {query} unexpectedly returned no results");
}
std::hint::black_box(results);
durations.push(started.elapsed().as_secs_f64() * 1000.0);
}
durations.sort_by(f64::total_cmp);
let percentile = |fraction: f64| {
durations[((durations.len() as f64 * fraction).ceil() as usize).saturating_sub(1)]
};
let p95_ms = percentile(0.95);
eprintln!("query {query:?}: p95 {p95_ms:.3} ms; target < {target:.0} ms");
queries.push(Measurements {
query,
samples,
p50_ms: percentile(0.5),
first_query_ms,
p95_ms,
target_p95_ms: target,
target_met: p95_ms < target,
});
}
let report = Report {
fixture:
"synthetic SQLite metadata and text; no filesystem extraction or semantic inference",
files,
documents,
seed_seconds,
migration_seconds,
fixture_reused: reuse,
database_bytes,
database_path: database.display().to_string(),
architecture: env::consts::ARCH,
os: env::consts::OS,
cpu_only: true,
queries,
};
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
+285 -59
View File
@@ -5,16 +5,18 @@
//! Worker isolation remains a production milestone. //! Worker isolation remains a production milestone.
mod extraction; mod extraction;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, params_from_iter, types::Value, Connection, OptionalExtension};
use serde::Serialize; use serde::Serialize;
use std::{ use std::{
cell::RefCell,
collections::{BTreeSet, HashMap},
fs, fs,
path::{Path, PathBuf}, path::{Path, PathBuf},
time::UNIX_EPOCH, time::UNIX_EPOCH,
}; };
use walkdir::WalkDir; use walkdir::WalkDir;
const SCHEMA_VERSION: i64 = 4; const SCHEMA_VERSION: i64 = 7;
const MAX_RESULTS: usize = 1000; const MAX_RESULTS: usize = 1000;
#[derive(Debug, Default, Serialize)] #[derive(Debug, Default, Serialize)]
@@ -92,6 +94,7 @@ pub struct IndexStats {
pub struct SearchIndex { pub struct SearchIndex {
connection: Connection, connection: Connection,
database_path: Option<PathBuf>, database_path: Option<PathBuf>,
gram_frequencies: RefCell<HashMap<String, i64>>,
} }
impl SearchIndex { impl SearchIndex {
@@ -159,16 +162,66 @@ impl SearchIndex {
CREATE TRIGGER IF NOT EXISTS files_passages_delete AFTER DELETE ON files BEGIN CREATE TRIGGER IF NOT EXISTS files_passages_delete AFTER DELETE ON files BEGIN
DELETE FROM passages WHERE file_path=old.path; END;", DELETE FROM passages WHERE file_path=old.path; END;",
)?; )?;
if version != 4 { if version > 0 && version < 4 {
// Re-extract records from older schemas to populate genuine locations. // Re-extract records from older schemas to populate genuine locations.
initialization.execute("UPDATE files SET mtime=-1", [])?; initialization.execute("UPDATE files SET mtime=-1", [])?;
} }
initialization.execute_batch("PRAGMA user_version=4;")?; if version < 5 {
initialization.execute_batch("CREATE TABLE passages_new(
id INTEGER PRIMARY KEY, text TEXT NOT NULL, file_path TEXT NOT NULL,
kind TEXT NOT NULL, number INTEGER NOT NULL);
INSERT INTO passages_new SELECT rowid,text,file_path,kind,number FROM passages;
DROP TRIGGER files_passages_delete;
DROP TABLE passages;
ALTER TABLE passages_new RENAME TO passages;
CREATE TRIGGER files_passages_delete AFTER DELETE ON files BEGIN
DELETE FROM passages WHERE file_path=old.path; END;
CREATE INDEX passages_file ON passages(file_path,id);
CREATE VIRTUAL TABLE passage_search USING fts5(text,content='passages',content_rowid='id',tokenize='unicode61');
CREATE TRIGGER passages_insert AFTER INSERT ON passages BEGIN
INSERT INTO passage_search(rowid,text) VALUES(new.id,new.text); END;
CREATE TRIGGER passages_delete AFTER DELETE ON passages BEGIN
INSERT INTO passage_search(passage_search,rowid,text) VALUES('delete',old.id,old.text); END;
CREATE TRIGGER passages_update AFTER UPDATE ON passages BEGIN
INSERT INTO passage_search(passage_search,rowid,text) VALUES('delete',old.id,old.text);
INSERT INTO passage_search(rowid,text) VALUES(new.id,new.text); END;
INSERT INTO passage_search(passage_search) VALUES('rebuild');
CREATE INDEX files_filename ON files(lower(filename),id);
CREATE VIRTUAL TABLE names USING fts5(filename,path,content='files',content_rowid='id',tokenize='trigram');
CREATE TRIGGER files_names_insert AFTER INSERT ON files BEGIN
INSERT INTO names(rowid,filename,path) VALUES(new.id,new.filename,new.path); END;
CREATE TRIGGER files_names_delete AFTER DELETE ON files BEGIN
INSERT INTO names(names,rowid,filename,path) VALUES('delete',old.id,old.filename,old.path); END;
CREATE TRIGGER files_names_update AFTER UPDATE ON files BEGIN
INSERT INTO names(names,rowid,filename,path) VALUES('delete',old.id,old.filename,old.path);
INSERT INTO names(rowid,filename,path) VALUES(new.id,new.filename,new.path); END;
INSERT INTO names(names) VALUES('rebuild');
PRAGMA user_version=5;")?;
}
if version < 6 {
initialization.execute_batch("CREATE VIRTUAL TABLE content_search USING fts5(content,
content='files',content_rowid='id',tokenize='unicode61');
CREATE TRIGGER files_content_insert AFTER INSERT ON files WHEN new.content!='' BEGIN
INSERT INTO content_search(rowid,content) VALUES(new.id,new.content); END;
CREATE TRIGGER files_content_delete AFTER DELETE ON files WHEN old.content!='' BEGIN
INSERT INTO content_search(content_search,rowid,content) VALUES('delete',old.id,old.content); END;
CREATE TRIGGER files_content_update AFTER UPDATE ON files BEGIN
INSERT INTO content_search(content_search,rowid,content)
SELECT 'delete',old.id,old.content WHERE old.content!='';
INSERT INTO content_search(rowid,content) SELECT new.id,new.content WHERE new.content!=''; END;
INSERT INTO content_search(rowid,content) SELECT id,content FROM files WHERE content!='';
PRAGMA user_version=6;")?;
}
initialization.execute_batch(
"CREATE VIRTUAL TABLE IF NOT EXISTS names_vocab USING fts5vocab(names,'row');
PRAGMA user_version=7;",
)?;
initialization.commit()?; initialization.commit()?;
} }
Ok(Self { Ok(Self {
connection, connection,
database_path, database_path,
gram_frequencies: RefCell::new(HashMap::new()),
}) })
} }
@@ -300,13 +353,14 @@ impl SearchIndex {
)?; )?;
transaction.execute("DELETE FROM seen_paths", [])?; transaction.execute("DELETE FROM seen_paths", [])?;
transaction.commit()?; transaction.commit()?;
self.gram_frequencies.borrow_mut().clear();
Ok(report) Ok(report)
} }
/// Literal input is escaped before FTS parsing; user text cannot inject operators. /// Literal input is escaped before FTS parsing; user text cannot inject operators.
/// Exact filenames precede partial filenames, which precede content matches. /// Exact filenames precede partial filenames, which precede content matches.
/// Filename substring matching currently scans metadata; a dedicated substring /// Substrings of at least three Unicode characters use a trigram index.
/// index is required before claiming million-file latency targets. /// Shorter substrings retain complete matching using a slower metadata scan.
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> { pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
self.search_with_options(query, limit, &SearchOptions::default()) self.search_with_options(query, limit, &SearchOptions::default())
} }
@@ -370,65 +424,237 @@ impl SearchIndex {
.map(|term| format!("\"{}\"", term.replace('"', "\"\""))) .map(|term| format!("\"{}\"", term.replace('"', "\"\"")))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" AND "); .join(" AND ");
let mut statement = self.connection.prepare( let limit = limit.min(MAX_RESULTS);
"WITH hits AS ( let filters = " AND (?3 IS NULL OR substr(f.path,1,length(?3))=?3)
SELECT rowid AS id, bm25(search, 8.0, 2.0, 1.0) AS rank, AND (?4 IS NULL OR f.extension=?4)
snippet(search, 2, '', '', ' … ', 24) AS excerpt AND (?5 IS NULL OR f.size>=?5) AND (?6 IS NULL OR f.size<=?6)
FROM search WHERE search MATCH ?1) AND (?7 IS NULL OR f.mtime/1000000>=?7) AND (?8 IS NULL OR f.mtime/1000000<=?8)";
SELECT f.path, f.filename, COALESCE(h.excerpt, ''), COALESCE(h.rank, 0), let filter_values = [
CASE WHEN lower(f.filename)=lower(?2) THEN 0 folder_prefix.map_or(Value::Null, Value::Text),
WHEN instr(lower(f.filename), lower(?2)) > 0 THEN 1 ELSE 2 END AS tier, f.extraction_error extension.map_or(Value::Null, Value::Text),
FROM files f LEFT JOIN hits h ON h.id=f.id min_size.map_or(Value::Null, Value::Integer),
WHERE (h.id IS NOT NULL OR instr(lower(f.filename), lower(?2)) > 0) max_size.map_or(Value::Null, Value::Integer),
AND (?4 IS NULL OR substr(f.path,1,length(?4))=?4) options
AND (?5 IS NULL OR f.extension=?5) .modified_after_unix_ms
AND (?6 IS NULL OR f.size>=?6) AND (?7 IS NULL OR f.size<=?7) .map_or(Value::Null, Value::Integer),
AND (?8 IS NULL OR f.mtime/1000000>=?8) AND (?9 IS NULL OR f.mtime/1000000<=?9) options
ORDER BY tier, h.rank, f.path LIMIT ?3", .modified_before_unix_ms
)?; .map_or(Value::Null, Value::Integer),
let mut results = statement ];
.query_map( let mut results = Vec::new();
params![ let mut selected = Vec::<i64>::new();
fts, // Each lane filters its entire candidate set before LIMIT. There is no
query, // hidden pre-filter candidate cap that can hide later matching files.
limit.min(MAX_RESULTS) as i64, let trigram = query.chars().count() >= 3;
folder_prefix, // A long FTS phrase may walk common leading trigram postings even when
extension, // later trigrams are rare. Use the rarest known trigram as a candidate
min_size, // superset, then verify the whole literal substring. The 256-term lookup
max_size, // budget can only enlarge candidates; it never caps results. Frequency
options.modified_after_unix_ms, // lookups are cached because FTS vocabulary counts have a first-use cost.
options.modified_before_unix_ms let characters = query.chars().collect::<Vec<_>>();
], let grams = characters
|row| { .windows(3)
let tier: i64 = row.get(4)?; .map(|window| window.iter().collect::<String>())
Ok(SearchResult { .collect::<BTreeSet<_>>()
path: row.get(0)?, .into_iter()
filename: row.get(1)?, .take(256)
snippet: row.get(2)?, .collect::<Vec<_>>();
score: -row.get::<_, f64>(3)?, let mut vocabulary = self
match_kind: match tier { .connection
0 => "exact_filename", .prepare("SELECT doc FROM names_vocab WHERE term=?1")?;
1 => "filename", let mut cache = self.gram_frequencies.borrow_mut();
_ => "content", if cache.len() + grams.len() > 4096 {
cache.clear();
} }
.to_owned(), let mut rare_first = grams
.into_iter()
.map(|gram| {
let term = gram.to_lowercase();
let frequency = if let Some(frequency) = cache.get(&term) {
*frequency
} else {
let frequency = vocabulary
.query_row([&term], |row| row.get::<_, i64>(0))
.optional()?
.unwrap_or(0);
cache.insert(term, frequency);
frequency
};
Ok((frequency, gram))
})
.collect::<Result<Vec<_>>>()?;
rare_first.sort();
drop(cache);
let grams = rare_first
.into_iter()
.take(1)
.map(|(_, gram)| format!("\"{}\"", gram.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(" AND ");
let mut lanes = vec![
(
"exact_filename",
"files f".to_owned(),
"lower(f.filename)=lower(?1)".to_owned(),
query.to_owned(),
"f.id",
),
(
"filename",
if trigram {
"names CROSS JOIN files f ON f.id=names.rowid".into()
} else {
"files f".into()
},
if trigram {
"names MATCH ?1 AND instr(lower(f.filename),lower(?2))>0 AND lower(f.filename)!=lower(?2)".into()
} else {
"instr(lower(f.filename),lower(?1))>0 AND lower(f.filename)!=lower(?2)".into()
},
if trigram {
format!("filename:({grams})")
} else {
query.into()
},
if trigram { "names.rowid" } else { "f.id" },
),
(
"path",
if trigram {
"names CROSS JOIN files f ON f.id=names.rowid".into()
} else {
"files f".into()
},
if trigram {
"names MATCH ?1 AND instr(lower(f.path),lower(?2))>0 AND instr(lower(f.filename),lower(?2))=0".into()
} else {
"instr(lower(f.path),lower(?1))>0 AND instr(lower(f.filename),lower(?2))=0"
.into()
},
if trigram {
format!("path:({grams})")
} else {
query.into()
},
if trigram { "names.rowid" } else { "f.id" },
),
(
"content",
"content_search CROSS JOIN files f ON f.id=content_search.rowid".into(),
"content_search MATCH ?1".into(),
fts.clone(),
"rank,f.id",
),
];
// Single-token names/identifiers use literal substring matching. A
// multiword query can also match token-separated names such as annual_report.
if query.split_whitespace().count() > 1 {
lanes.push((
"name_terms",
"search CROSS JOIN files f ON f.id=search.rowid".into(),
"search MATCH ?1 AND rank MATCH 'bm25(8.0,2.0,1.0)'".into(),
fts.clone(),
"rank,f.id",
));
}
for (kind, from, condition, expression, ordering) in lanes {
if results.len() == limit {
break;
}
let exclusions = if selected.is_empty() {
String::new()
} else {
format!(
" AND f.id NOT IN ({})",
selected
.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join(",")
)
};
let rank = if matches!(kind, "content" | "name_terms") {
"rank"
} else {
"0.0"
};
let snippet = if kind == "content" {
"snippet(content_search,0,'','',' … ',24)"
} else if kind == "name_terms" {
"snippet(search,2,'','',' … ',24)"
} else {
"''"
};
let sql = format!(
"SELECT f.id,f.path,f.filename,{snippet},{rank},f.extraction_error FROM {from}
WHERE {condition} {filters} {exclusions} ORDER BY {ordering} LIMIT ?9"
);
let mut values = vec![Value::Text(expression), Value::Text(query.into())];
values.extend(filter_values.iter().cloned());
values.push(Value::Integer((limit - results.len()) as i64));
let mut statement = self.connection.prepare(&sql)?;
let matches = statement
.query_map(params_from_iter(values), |row| {
Ok((
row.get::<_, i64>(0)?,
SearchResult {
path: row.get(1)?,
filename: row.get(2)?,
snippet: row.get(3)?,
score: -row.get::<_, f64>(4)?,
match_kind: kind.into(),
source: None, source: None,
extraction_error: row.get(5)?, extraction_error: row.get(5)?,
})
}, },
)? ))
})?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
let mut passages = self.connection.prepare("SELECT kind,number,snippet(passages,0,'','',' … ',24) for (id, result) in matches {
FROM passages WHERE passages MATCH ?1 AND file_path=?2 ORDER BY bm25(passages),rowid LIMIT 1")?; selected.push(id);
results.push(result);
}
}
if !results.is_empty() {
// Ownership has a B-tree index. The rowid IN constraint intersects
// the FTS postings with only the actual finalists' passage IDs.
let placeholders = (0..results.len())
.map(|_| "?")
.collect::<Vec<_>>()
.join(",");
let sql = format!("WITH matches AS MATERIALIZED (
SELECT p.file_path,p.kind,p.number,p.id,snippet(passage_search,0,'','',' … ',24) AS excerpt,
bm25(passage_search) AS relevance
FROM passage_search JOIN passages p ON p.id=passage_search.rowid
WHERE passage_search MATCH ? AND passage_search.rowid IN (
SELECT id FROM passages WHERE file_path IN ({placeholders})))
SELECT file_path,kind,number,excerpt FROM (
SELECT *,row_number() OVER(PARTITION BY file_path ORDER BY relevance,id) AS ordinal FROM matches)
WHERE ordinal=1");
let values = std::iter::once(Value::Text(fts)).chain(
results
.iter()
.map(|result| Value::Text(result.path.clone())),
);
let mut statement = self.connection.prepare(&sql)?;
let sources = statement
.query_map(params_from_iter(values), |row| {
Ok((
row.get::<_, String>(0)?,
(
SourceLocation {
kind: row.get(1)?,
number: row.get(2)?,
},
row.get::<_, String>(3)?,
),
))
})?
.collect::<rusqlite::Result<HashMap<_, _>>>()?;
for result in &mut results { for result in &mut results {
let passage: Option<(String, u32, String)> = passages if let Some((source, snippet)) = sources.get(&result.path) {
.query_row(params![fts, result.path], |row| { result.source = Some(source.clone());
Ok((row.get(0)?, row.get(1)?, row.get(2)?)) result.snippet = snippet.clone();
}) }
.optional()?;
if let Some((kind, number, snippet)) = passage {
result.source = Some(SourceLocation { kind, number });
result.snippet = snippet;
} }
} }
Ok(results) Ok(results)
@@ -0,0 +1,125 @@
use anyhow::Result;
use searchi_core::{SearchIndex, SearchOptions};
use std::fs;
#[test]
fn filters_do_not_hide_late_matches_in_any_lane() -> Result<()> {
let root = tempfile::tempdir()?;
let rejected = root.path().join("early");
let wanted = root.path().join("late");
fs::create_dir(&rejected)?;
fs::create_dir(&wanted)?;
let db = root.path().join("index.db");
let mut index = SearchIndex::open(&db)?;
for number in 0..2000 {
fs::write(
rejected.join(format!("shared_{number:04}.txt")),
"shared retrieval term",
)?;
}
index.index_folder(&rejected)?;
fs::write(
wanted.join("shared_last.md"),
"shared retrieval term unique-evidence",
)?;
index.index_folder(&wanted)?;
let options = SearchOptions {
folder: Some(wanted.clone()),
extension: Some("md".into()),
..Default::default()
};
assert_eq!(
index.search_with_options("shared", 1, &options)?[0].filename,
"shared_last.md"
);
assert_eq!(
index.search_with_options("retrieval", 1, &options)?[0].filename,
"shared_last.md"
);
assert_eq!(
index.search_with_options("ha", 1, &options)?[0].filename,
"shared_last.md"
);
let result = index.search_with_options("unique-evidence", 1, &options)?;
assert!(result[0].source.is_some());
assert!(result[0].snippet.contains("unique-evidence"));
Ok(())
}
#[test]
fn exact_priority_literal_identifiers_and_index_lifecycle() -> Result<()> {
let root = tempfile::tempdir()?;
fs::create_dir(root.path().join("contracts"))?;
fs::write(
root.path().join("name_[%].txt"),
"identifier ABC-123 and term",
)?;
fs::write(root.path().join("name_[%].txt.backup"), "term")?;
fs::write(
root.path().join("contracts").join("other.md"),
"name identifier ABC-123",
)?;
fs::write(root.path().join("annual_report.md"), "unrelated text")?;
fs::write(root.path().join("abc_missing_xyz.txt"), "unrelated text")?;
let mut index = SearchIndex::open(":memory:")?;
index.index_folder(root.path())?;
let result = index.search("NAME_[%].TXT", 10)?;
assert_eq!(result[0].match_kind, "exact_filename");
assert_eq!(result[1].match_kind, "filename");
assert_eq!(index.search("contracts", 1)?[0].match_kind, "path");
assert_eq!(index.search("ABC-123", 10)?.len(), 2);
assert_eq!(
index.search("annual report", 1)?[0].filename,
"annual_report.md"
);
assert!(index.search("abc_xyz", 10)?.is_empty());
let first = index
.search("name", 10)?
.into_iter()
.map(|row| row.path)
.collect::<Vec<_>>();
let second = index
.search("name", 10)?
.into_iter()
.map(|row| row.path)
.collect::<Vec<_>>();
assert_eq!(first, second);
fs::rename(
root.path().join("name_[%].txt"),
root.path().join("replacement.txt"),
)?;
fs::remove_file(root.path().join("name_[%].txt.backup"))?;
index.index_folder(root.path())?;
assert!(index.search("name_[%].txt", 10)?.is_empty());
assert_eq!(index.search("placement", 1)?[0].filename, "replacement.txt");
Ok(())
}
#[test]
fn v4_migration_backfills_names_and_preserves_passage_ids() -> Result<()> {
let root = tempfile::tempdir()?;
let db = root.path().join("old.db");
let connection = rusqlite::Connection::open(&db)?;
connection.execute_batch("CREATE TABLE files(id INTEGER PRIMARY KEY,path TEXT NOT NULL UNIQUE,
root TEXT NOT NULL,filename TEXT NOT NULL,content TEXT NOT NULL,size INTEGER NOT NULL,mtime INTEGER NOT NULL,
extension TEXT NOT NULL,extraction_error TEXT);
CREATE VIRTUAL TABLE search USING fts5(filename,path,content,content='files',content_rowid='id',tokenize='unicode61');
CREATE VIRTUAL TABLE passages USING fts5(text,file_path UNINDEXED,kind UNINDEXED,number UNINDEXED);
INSERT INTO files VALUES(41,'/fixture/report.pdf','/fixture','report.pdf','preserved evidence',18,123,'pdf',NULL);
INSERT INTO search(search) VALUES('rebuild');
INSERT INTO passages(rowid,text,file_path,kind,number) VALUES(88,'preserved evidence','/fixture/report.pdf','page',7);
PRAGMA user_version=4;")?;
drop(connection);
let index = SearchIndex::open(&db)?;
assert_eq!(index.search("port.p", 1)?[0].filename, "report.pdf");
assert_eq!(
index.search("evidence", 1)?[0]
.source
.as_ref()
.unwrap()
.number,
7
);
assert_eq!(index.passage_batch(0, 1)?[0].id, 88);
Ok(())
}