feat: add desktop document search and verified native CPU embeddings
Core CI / core (ubuntu-latest) (push) Failing after 1m22s
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 3m46s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
Core CI / core (ubuntu-latest) (push) Failing after 1m22s
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 3m46s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
This commit is contained in:
1 parent
f77112db97
commit
a9b6592330
46 files changed
+10794
-77
No files matched your search
@@ -9,6 +9,10 @@ anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
walkdir = "2"
|
||||
lopdf = { version = "0.45", default-features = false }
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.42"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
serde_json.workspace = true
|
||||
@@ -4,10 +4,14 @@ The core has no desktop or inference dependencies. Its current retrieval engine
|
||||
combines literal filename matching with SQLite FTS5 BM25 keyword search. It does
|
||||
not implement semantic search.
|
||||
|
||||
`SearchIndex::open` creates or migrates schema version 2. Current-schema opens do
|
||||
`SearchIndex::open` creates or migrates schema version 4. Current-schema opens do
|
||||
not acquire a write lock. WAL allows independent readers to use the committed
|
||||
index while reconciliation runs. All indexing changes are committed atomically;
|
||||
a traversal or supported-file read error rolls back the entire reconciliation.
|
||||
a traversal or plain-text file read error rolls back the entire reconciliation.
|
||||
PDF/DOCX parser or limit failures retain a filename record with a persisted
|
||||
`SearchResult.extraction_error`, clear stale content, and increment
|
||||
`IndexReport.failures`. This counts attempts in this reconciliation, so an
|
||||
unchanged failed record retains its reason without incrementing the count again.
|
||||
|
||||
`index_folder` applies `IndexOptions::default()`: directories named `.git`,
|
||||
`node_modules`, and `target` are pruned. `index_folder_with_options` lets callers
|
||||
@@ -23,6 +27,46 @@ or size. Unsupported files remain searchable by filename. Content reads are
|
||||
limited to 2 MiB. Reconciliation tracks seen files in SQLite rather than retaining
|
||||
every path in process memory.
|
||||
|
||||
PDF text extraction uses `lopdf` with strict parsing and bounded load/page/font
|
||||
stream decompression. DOCX uses `zip` and namespace-aware `quick-xml` to read the
|
||||
main document body, including table paragraphs. The extractor does not execute
|
||||
macros or follow external document relationships. It supports UTF-8 DOCX XML;
|
||||
UTF-16 document parts, nested paragraphs, and XML document types are explicit
|
||||
failures. Headers, footers, comments, drawings, and tracked-deletion semantics
|
||||
are not yet covered. Encrypted PDFs are explicitly unsupported. Scanned PDFs
|
||||
without a text layer remain filename searchable; OCR is not implemented.
|
||||
|
||||
| Limit | Value |
|
||||
| --- | --- |
|
||||
| PDF/DOCX file input, before parsing | 32 MiB |
|
||||
| PDF load stream / page content / font decoding | 4 MiB per stream or page budget |
|
||||
| PDF page count, after parsing | 1,000 |
|
||||
| DOCX main XML, including decompressed ZIP output | 8 MiB |
|
||||
| DOCX ZIP entries / paragraphs | 10,000 each |
|
||||
| DOCX XML depth | 128 |
|
||||
| Extracted text per document | 2 MiB |
|
||||
|
||||
Input reads and DOCX XML decompression are capped while reading. PDF's loader and
|
||||
text extractor use the library's bounded decoding APIs. Page/entry counts and
|
||||
total PDF extracted text are checked after the parser produces them. These are
|
||||
not global process memory or CPU-time limits: multiple PDF objects/fonts can each
|
||||
consume their budget, and parsing may still be expensive. Process isolation and
|
||||
timeouts remain required before handling arbitrary untrusted documents in a
|
||||
production release.
|
||||
|
||||
Actual page/paragraph text is stored separately in an FTS passage index. Search
|
||||
results use the best passage that matches all literal query terms and expose
|
||||
`source: { kind, number }`, with one-based PDF page, DOCX paragraph, or plain-text
|
||||
line-range start. Paragraph numbers include empty paragraphs. A filename-only
|
||||
match or a match spanning several passages can have no source; the engine does
|
||||
not invent a location. Snippets with a source are drawn from that passage.
|
||||
|
||||
`passage_batch(after_id, limit)` exposes at most 256 `IndexedPassage` records for
|
||||
real embedding runtimes. IDs represent current rows and can be reused after
|
||||
reconciliation; downstream embedding caches must validate content and model
|
||||
fingerprints. Consumers must tokenize long pages/paragraphs into overlapping
|
||||
windows rather than silently truncate them to a model token limit.
|
||||
|
||||
`search_with_options` supports canonical folder boundaries, extension, inclusive
|
||||
size bounds, and inclusive Unix millisecond modification-time bounds. Filters
|
||||
apply before limiting results. `search` retains the unfiltered API. Query text is
|
||||
@@ -46,4 +90,6 @@ escaped as literal FTS terms, not interpreted as operators. Results are capped a
|
||||
- Extraction runs in the caller and a large reconciliation holds one transaction.
|
||||
Bounded worker isolation, persistent jobs, and crash-resumable scans remain
|
||||
production milestones.
|
||||
- PDF, DOCX, OCR, media extraction, and real model inference remain unimplemented.
|
||||
- OCR, media extraction, and real model inference belong to subsequent modules.
|
||||
- Passage selection currently adds a bounded query per result; query batching and
|
||||
passage ranking benchmarks remain necessary for large result lists.
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Bounded inputs and outputs; parser allocations and CPU are not process isolated.
|
||||
use crate::SourceLocation;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use quick_xml::{events::Event, name::ResolveResult, NsReader, XmlVersion};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{Cursor, Read},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
const MAX_TEXT: usize = 2 * 1024 * 1024;
|
||||
const MAX_DOCUMENT: usize = 32 * 1024 * 1024;
|
||||
const MAX_XML: usize = 8 * 1024 * 1024;
|
||||
const MAX_PDF_STREAM: usize = 4 * 1024 * 1024;
|
||||
const MAX_PASSAGES: usize = 10_000;
|
||||
|
||||
pub(crate) struct Passage {
|
||||
pub text: String,
|
||||
pub source: SourceLocation,
|
||||
}
|
||||
|
||||
pub(crate) fn extract(path: &Path) -> Result<Option<Vec<Passage>>> {
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if extension == "pdf" || extension == "docx" {
|
||||
let bytes = read_bounded(path, MAX_DOCUMENT)?;
|
||||
return if extension == "pdf" {
|
||||
pdf(&bytes).map(Some)
|
||||
} else {
|
||||
docx(&bytes).map(Some)
|
||||
};
|
||||
}
|
||||
if !matches!(
|
||||
extension.as_str(),
|
||||
"txt"
|
||||
| "md"
|
||||
| "rs"
|
||||
| "py"
|
||||
| "js"
|
||||
| "ts"
|
||||
| "tsx"
|
||||
| "jsx"
|
||||
| "json"
|
||||
| "toml"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "csv"
|
||||
| "html"
|
||||
| "css"
|
||||
| "xml"
|
||||
| "log"
|
||||
| "sh"
|
||||
) {
|
||||
return Ok(None);
|
||||
}
|
||||
let file = File::open(path)?;
|
||||
if file.metadata()?.len() > MAX_TEXT as u64 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_TEXT as u64 + 1).read_to_end(&mut bytes)?;
|
||||
if bytes.len() > MAX_TEXT || bytes.contains(&0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Ok(text) = String::from_utf8(bytes) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let lines = text.split_inclusive('\n').collect::<Vec<_>>();
|
||||
Ok(Some(
|
||||
lines
|
||||
.chunks(64)
|
||||
.enumerate()
|
||||
.map(|(index, lines)| Passage {
|
||||
text: lines.concat(),
|
||||
source: SourceLocation {
|
||||
kind: "line".into(),
|
||||
number: (index * 64 + 1) as u32,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn read_bounded(path: &Path, maximum: usize) -> Result<Vec<u8>> {
|
||||
let file = File::open(path)?;
|
||||
if file.metadata()?.len() > maximum as u64 {
|
||||
bail!("document input exceeds {maximum} bytes");
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(maximum as u64 + 1).read_to_end(&mut bytes)?;
|
||||
if bytes.len() > maximum {
|
||||
bail!("document input exceeds {maximum} bytes");
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn pdf(bytes: &[u8]) -> Result<Vec<Passage>> {
|
||||
let document = lopdf::Document::load_mem_with_options(
|
||||
bytes,
|
||||
lopdf::LoadOptions {
|
||||
strict: true,
|
||||
max_decompressed_size: Some(MAX_PDF_STREAM),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.context("invalid, encrypted, or over-limit PDF")?;
|
||||
if document.was_encrypted() || document.is_encrypted() {
|
||||
bail!("encrypted PDF is unsupported");
|
||||
}
|
||||
let pages = document.get_pages();
|
||||
if pages.is_empty() {
|
||||
bail!("PDF has no pages");
|
||||
}
|
||||
if pages.len() > 1000 {
|
||||
bail!("PDF exceeds 1000 pages");
|
||||
}
|
||||
let mut passages = Vec::new();
|
||||
let mut total = 0;
|
||||
for number in pages.keys() {
|
||||
let text = document
|
||||
.extract_text_with_limit(&[*number], MAX_PDF_STREAM)
|
||||
.with_context(|| format!("PDF text extraction failed on page {number}"))?;
|
||||
total += text.len();
|
||||
if total > MAX_TEXT {
|
||||
bail!("PDF text exceeds {MAX_TEXT} bytes");
|
||||
}
|
||||
if !text.trim().is_empty() {
|
||||
passages.push(Passage {
|
||||
text,
|
||||
source: SourceLocation {
|
||||
kind: "page".into(),
|
||||
number: *number,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(passages)
|
||||
}
|
||||
|
||||
fn docx(bytes: &[u8]) -> Result<Vec<Passage>> {
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(Cursor::new(bytes)).context("invalid DOCX ZIP container")?;
|
||||
if archive.len() > 10_000 {
|
||||
bail!("DOCX exceeds 10000 archive entries");
|
||||
}
|
||||
let mut xml = String::new();
|
||||
let part = archive
|
||||
.by_name("word/document.xml")
|
||||
.context("DOCX lacks word/document.xml")?;
|
||||
if part.size() > MAX_XML as u64 {
|
||||
bail!("DOCX document XML exceeds {MAX_XML} bytes");
|
||||
}
|
||||
part.take(MAX_XML as u64 + 1)
|
||||
.read_to_string(&mut xml)
|
||||
.context("DOCX XML must be valid UTF-8")?;
|
||||
if xml.len() > MAX_XML {
|
||||
bail!("DOCX document XML exceeds {MAX_XML} bytes");
|
||||
}
|
||||
let mut reader = NsReader::from_str(&xml);
|
||||
let mut passages = Vec::new();
|
||||
let mut paragraph = String::new();
|
||||
let mut number = 0;
|
||||
let mut in_paragraph = false;
|
||||
let mut in_text = false;
|
||||
let mut total = 0;
|
||||
let mut depth = 0usize;
|
||||
let mut saw_document = false;
|
||||
loop {
|
||||
let (namespace, event) = reader.read_resolved_event().context("invalid DOCX XML")?;
|
||||
let word_namespace = matches!(namespace, ResolveResult::Bound(value) if matches!(value.as_ref(),
|
||||
"http://schemas.openxmlformats.org/wordprocessingml/2006/main" | "http://purl.oclc.org/ooxml/wordprocessingml/main"));
|
||||
match &event {
|
||||
Event::Start(start) => {
|
||||
if depth == 0 {
|
||||
if saw_document || !word_namespace || start.local_name().as_ref() != "document"
|
||||
{
|
||||
bail!("invalid DOCX document root");
|
||||
}
|
||||
saw_document = true;
|
||||
}
|
||||
depth += 1;
|
||||
if depth > 128 {
|
||||
bail!("DOCX XML exceeds 128 nesting levels");
|
||||
}
|
||||
}
|
||||
Event::End(_) => depth = depth.checked_sub(1).context("invalid DOCX XML depth")?,
|
||||
Event::Empty(_) if depth == 0 => bail!("invalid DOCX document root"),
|
||||
_ => {}
|
||||
}
|
||||
match event {
|
||||
Event::Start(event) if word_namespace => match event.local_name().as_ref() {
|
||||
"p" => {
|
||||
if in_paragraph {
|
||||
bail!("nested DOCX paragraphs are unsupported");
|
||||
}
|
||||
number += 1;
|
||||
if number > MAX_PASSAGES {
|
||||
bail!("DOCX exceeds 10000 paragraphs");
|
||||
}
|
||||
in_paragraph = true;
|
||||
paragraph.clear();
|
||||
}
|
||||
"t" => in_text = in_paragraph,
|
||||
_ => {}
|
||||
},
|
||||
Event::End(event) if word_namespace => match event.local_name().as_ref() {
|
||||
"t" => in_text = false,
|
||||
"p" => {
|
||||
in_paragraph = false;
|
||||
in_text = false;
|
||||
total += paragraph.len();
|
||||
if total > MAX_TEXT {
|
||||
bail!("DOCX text exceeds {MAX_TEXT} bytes");
|
||||
}
|
||||
if !paragraph.trim().is_empty() {
|
||||
passages.push(Passage {
|
||||
text: std::mem::take(&mut paragraph),
|
||||
source: SourceLocation {
|
||||
kind: "paragraph".into(),
|
||||
number: number as u32,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::Empty(event) if word_namespace => match event.local_name().as_ref() {
|
||||
"tab" if in_paragraph => paragraph.push('\t'),
|
||||
"br" | "cr" if in_paragraph => paragraph.push('\n'),
|
||||
"p" => {
|
||||
number += 1;
|
||||
if number > MAX_PASSAGES {
|
||||
bail!("DOCX exceeds 10000 paragraphs");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::Text(event) if in_text => {
|
||||
paragraph.push_str(&event.xml_content(XmlVersion::Implicit1_0))
|
||||
}
|
||||
Event::CData(event) if in_text => {
|
||||
paragraph.push_str(&event.xml_content(XmlVersion::Implicit1_0))
|
||||
}
|
||||
Event::GeneralRef(event) if in_text => {
|
||||
let reference = format!("&{};", event.as_ref());
|
||||
paragraph.push_str(
|
||||
&quick_xml::escape::unescape(&reference).context("unsupported DOCX entity")?,
|
||||
);
|
||||
}
|
||||
Event::DocType(_) => bail!("DOCX XML document types are unsupported"),
|
||||
Event::Eof => {
|
||||
if in_paragraph || depth != 0 || !saw_document {
|
||||
bail!("incomplete DOCX document");
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if total + paragraph.len() > MAX_TEXT {
|
||||
bail!("DOCX text exceeds {MAX_TEXT} bytes");
|
||||
}
|
||||
}
|
||||
Ok(passages)
|
||||
}
|
||||
+120
-56
@@ -1,21 +1,20 @@
|
||||
//! Offline lexical search engine shared by the CLI and desktop shell.
|
||||
//!
|
||||
//! Reconciliation is atomic. Incomplete directory walks never delete records.
|
||||
//! Text extraction is deliberately bounded; binary document extractors belong in
|
||||
//! isolated workers in the next milestone, not in this indexing transaction.
|
||||
//! Text and document extraction have byte limits, but still run in this process.
|
||||
//! Worker isolation remains a production milestone.
|
||||
mod extraction;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
fs,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
time::UNIX_EPOCH,
|
||||
};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
const SCHEMA_VERSION: i64 = 2;
|
||||
const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024;
|
||||
const SCHEMA_VERSION: i64 = 4;
|
||||
const MAX_RESULTS: usize = 1000;
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
@@ -25,6 +24,7 @@ pub struct IndexReport {
|
||||
pub removed: usize,
|
||||
pub skipped: usize,
|
||||
pub unchanged: usize,
|
||||
pub failures: usize,
|
||||
}
|
||||
|
||||
/// Names of directories to prune anywhere below an explicitly chosen root.
|
||||
@@ -60,6 +60,27 @@ pub struct SearchResult {
|
||||
pub snippet: String,
|
||||
pub score: f64,
|
||||
pub match_kind: String,
|
||||
pub source: Option<SourceLocation>,
|
||||
pub extraction_error: Option<String>,
|
||||
}
|
||||
|
||||
/// A verified matching passage location; numbers are one based.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SourceLocation {
|
||||
pub kind: String,
|
||||
pub number: u32,
|
||||
}
|
||||
|
||||
/// A bounded corpus page for downstream real embedding runtimes. IDs identify
|
||||
/// current index rows, not immutable content; consumers must validate content
|
||||
/// fingerprints across reconciliations before reusing persisted embeddings.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IndexedPassage {
|
||||
pub id: i64,
|
||||
pub path: String,
|
||||
pub filename: String,
|
||||
pub text: String,
|
||||
pub source: SourceLocation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -103,7 +124,7 @@ impl SearchIndex {
|
||||
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 DEFAULT -1, mtime INTEGER NOT NULL DEFAULT -1,
|
||||
extension TEXT NOT NULL DEFAULT '');
|
||||
extension TEXT NOT NULL DEFAULT '', extraction_error TEXT);
|
||||
CREATE INDEX IF NOT EXISTS files_root ON files(root);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(filename, path, content,
|
||||
content='files', content_rowid='id', tokenize='unicode61');
|
||||
@@ -128,9 +149,21 @@ impl SearchIndex {
|
||||
ALTER TABLE files ADD COLUMN extension TEXT NOT NULL DEFAULT '';
|
||||
PRAGMA user_version=2;",
|
||||
)?;
|
||||
} else {
|
||||
initialization.execute_batch("PRAGMA user_version=2;")?;
|
||||
}
|
||||
if version > 0 && version < 4 {
|
||||
initialization.execute("ALTER TABLE files ADD COLUMN extraction_error TEXT", [])?;
|
||||
}
|
||||
initialization.execute_batch(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS passages USING fts5(
|
||||
text, file_path UNINDEXED, kind UNINDEXED, number UNINDEXED, tokenize='unicode61');
|
||||
CREATE TRIGGER IF NOT EXISTS files_passages_delete AFTER DELETE ON files BEGIN
|
||||
DELETE FROM passages WHERE file_path=old.path; END;",
|
||||
)?;
|
||||
if version != 4 {
|
||||
// Re-extract records from older schemas to populate genuine locations.
|
||||
initialization.execute("UPDATE files SET mtime=-1", [])?;
|
||||
}
|
||||
initialization.execute_batch("PRAGMA user_version=4;")?;
|
||||
initialization.commit()?;
|
||||
}
|
||||
Ok(Self {
|
||||
@@ -218,17 +251,46 @@ impl SearchIndex {
|
||||
report.unchanged += 1;
|
||||
continue;
|
||||
}
|
||||
let content = extract_text(entry.path()).with_context(|| format!("read {path}"))?;
|
||||
if content.is_none() {
|
||||
let (extracted, extraction_error) = match extraction::extract(entry.path()) {
|
||||
Ok(extracted) => (extracted, None),
|
||||
Err(error) if matches!(extension.as_str(), "pdf" | "docx") => {
|
||||
report.failures += 1;
|
||||
let reason: String = format!("{error:#}").chars().take(2048).collect();
|
||||
(None, Some(reason))
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(error)
|
||||
.with_context(|| format!("read {path}; reconciliation rolled back"))
|
||||
}
|
||||
};
|
||||
if extracted.is_none() {
|
||||
report.skipped += 1;
|
||||
}
|
||||
let extracted = extracted.unwrap_or_default();
|
||||
let content = extracted
|
||||
.iter()
|
||||
.map(|passage| passage.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
transaction.execute(
|
||||
"INSERT INTO files(path,root,filename,content,size,mtime,extension) VALUES (?1,?2,?3,?4,?5,?6,?7)
|
||||
"INSERT INTO files(path,root,filename,content,size,mtime,extension,extraction_error) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)
|
||||
ON CONFLICT(path) DO UPDATE SET root=excluded.root, filename=excluded.filename,
|
||||
content=excluded.content, size=excluded.size, mtime=excluded.mtime,
|
||||
extension=excluded.extension",
|
||||
params![path, root_key, filename, content.unwrap_or_default(), size, mtime, extension],
|
||||
extension=excluded.extension, extraction_error=excluded.extraction_error",
|
||||
params![path, root_key, filename, content, size, mtime, extension, extraction_error],
|
||||
)?;
|
||||
transaction.execute("DELETE FROM passages WHERE file_path=?1", [&path])?;
|
||||
for passage in extracted {
|
||||
transaction.execute(
|
||||
"INSERT INTO passages(text,file_path,kind,number) VALUES (?1,?2,?3,?4)",
|
||||
params![
|
||||
passage.text,
|
||||
path,
|
||||
passage.source.kind,
|
||||
passage.source.number
|
||||
],
|
||||
)?;
|
||||
}
|
||||
report.indexed += 1;
|
||||
}
|
||||
report.removed = transaction.execute(
|
||||
@@ -315,7 +377,7 @@ impl SearchIndex {
|
||||
FROM search WHERE search MATCH ?1)
|
||||
SELECT f.path, f.filename, COALESCE(h.excerpt, ''), COALESCE(h.rank, 0),
|
||||
CASE WHEN lower(f.filename)=lower(?2) THEN 0
|
||||
WHEN instr(lower(f.filename), lower(?2)) > 0 THEN 1 ELSE 2 END AS tier
|
||||
WHEN instr(lower(f.filename), lower(?2)) > 0 THEN 1 ELSE 2 END AS tier, f.extraction_error
|
||||
FROM files f LEFT JOIN hits h ON h.id=f.id
|
||||
WHERE (h.id IS NOT NULL OR instr(lower(f.filename), lower(?2)) > 0)
|
||||
AND (?4 IS NULL OR substr(f.path,1,length(?4))=?4)
|
||||
@@ -324,7 +386,7 @@ impl SearchIndex {
|
||||
AND (?8 IS NULL OR f.mtime/1000000>=?8) AND (?9 IS NULL OR f.mtime/1000000<=?9)
|
||||
ORDER BY tier, h.rank, f.path LIMIT ?3",
|
||||
)?;
|
||||
let results = statement
|
||||
let mut results = statement
|
||||
.query_map(
|
||||
params![
|
||||
fts,
|
||||
@@ -350,13 +412,56 @@ impl SearchIndex {
|
||||
_ => "content",
|
||||
}
|
||||
.to_owned(),
|
||||
source: None,
|
||||
extraction_error: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let mut passages = self.connection.prepare("SELECT kind,number,snippet(passages,0,'','',' … ',24)
|
||||
FROM passages WHERE passages MATCH ?1 AND file_path=?2 ORDER BY bm25(passages),rowid LIMIT 1")?;
|
||||
for result in &mut results {
|
||||
let passage: Option<(String, u32, String)> = passages
|
||||
.query_row(params![fts, result.path], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})
|
||||
.optional()?;
|
||||
if let Some((kind, number, snippet)) = passage {
|
||||
result.source = Some(SourceLocation { kind, number });
|
||||
result.snippet = snippet;
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub fn contains_path(&self, path: &str) -> Result<bool> {
|
||||
Ok(self.connection.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM files WHERE path=?1)",
|
||||
[path],
|
||||
|row| row.get(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn passage_batch(&self, after_id: i64, limit: usize) -> Result<Vec<IndexedPassage>> {
|
||||
let mut statement = self.connection.prepare("SELECT p.rowid,f.path,f.filename,p.text,p.kind,p.number
|
||||
FROM passages p JOIN files f ON f.path=p.file_path WHERE p.rowid>?1 ORDER BY p.rowid LIMIT ?2")?;
|
||||
let passages = statement
|
||||
.query_map(params![after_id, limit.min(256) as i64], |row| {
|
||||
Ok(IndexedPassage {
|
||||
id: row.get(0)?,
|
||||
path: row.get(1)?,
|
||||
filename: row.get(2)?,
|
||||
text: row.get(3)?,
|
||||
source: SourceLocation {
|
||||
kind: row.get(4)?,
|
||||
number: row.get(5)?,
|
||||
},
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(passages)
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> Result<IndexStats> {
|
||||
Ok(self.connection.query_row(
|
||||
"SELECT count(*), count(NULLIF(content, '')) FROM files",
|
||||
@@ -371,47 +476,6 @@ impl SearchIndex {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_text(path: &Path) -> Result<Option<String>> {
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if !matches!(
|
||||
extension.as_str(),
|
||||
"txt"
|
||||
| "md"
|
||||
| "rs"
|
||||
| "py"
|
||||
| "js"
|
||||
| "ts"
|
||||
| "tsx"
|
||||
| "jsx"
|
||||
| "json"
|
||||
| "toml"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "csv"
|
||||
| "html"
|
||||
| "css"
|
||||
| "xml"
|
||||
| "log"
|
||||
| "sh"
|
||||
) {
|
||||
return Ok(None);
|
||||
}
|
||||
let file = fs::File::open(path)?;
|
||||
if file.metadata()?.len() > MAX_TEXT_BYTES {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_TEXT_BYTES + 1).read_to_end(&mut bytes)?;
|
||||
if bytes.len() as u64 > MAX_TEXT_BYTES || bytes.contains(&0) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(String::from_utf8(bytes).ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
use anyhow::Result;
|
||||
use lopdf::{
|
||||
content::{Content, Operation},
|
||||
dictionary, Document, Object, Stream,
|
||||
};
|
||||
use searchi_core::SearchIndex;
|
||||
use std::{fs, io::Write, path::Path};
|
||||
|
||||
fn pdf(path: &Path) -> Result<()> {
|
||||
pdf_with_text(
|
||||
path,
|
||||
&[
|
||||
"First introductory page",
|
||||
"Supplier cancellation clause on second page",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn pdf_with_text(path: &Path, texts: &[&str]) -> Result<()> {
|
||||
let mut document = Document::with_version("1.5");
|
||||
let pages_id = document.new_object_id();
|
||||
let font = document.add_object(dictionary! {"Type"=>"Font", "Subtype"=>"Type1", "BaseFont"=>"Helvetica", "Encoding"=>"WinAnsiEncoding"});
|
||||
let resources = document.add_object(dictionary! {"Font"=>dictionary! {"F1"=>font}});
|
||||
let mut pages = Vec::new();
|
||||
for text in texts {
|
||||
let content = Content {
|
||||
operations: vec![
|
||||
Operation::new("BT", vec![]),
|
||||
Operation::new("Tf", vec![Object::Name(b"F1".to_vec()), 12.into()]),
|
||||
Operation::new("Td", vec![50.into(), 700.into()]),
|
||||
Operation::new("Tj", vec![Object::string_literal(*text)]),
|
||||
Operation::new("ET", vec![]),
|
||||
],
|
||||
};
|
||||
let stream = document.add_object(Stream::new(dictionary! {}, content.encode()?));
|
||||
let page = document
|
||||
.add_object(dictionary! {"Type"=>"Page", "Parent"=>pages_id, "Contents"=>stream});
|
||||
pages.push(Object::Reference(page));
|
||||
}
|
||||
document.objects.insert(pages_id, Object::Dictionary(dictionary! {"Type"=>"Pages", "Kids"=>pages,
|
||||
"Count"=>texts.len() as i64, "Resources"=>resources, "MediaBox"=>vec![0.into(),0.into(),595.into(),842.into()]}));
|
||||
let catalog = document.add_object(dictionary! {"Type"=>"Catalog", "Pages"=>pages_id});
|
||||
document.trailer.set("Root", catalog);
|
||||
document.compress();
|
||||
document.save(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_document_limits_are_enforced() -> Result<()> {
|
||||
let root = tempfile::tempdir()?;
|
||||
let text = "a".repeat(4 * 1024 * 1024 + 100);
|
||||
pdf_with_text(&root.path().join("bomb.pdf"), &[&text])?;
|
||||
let xml = format!(
|
||||
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>{}</w:t></w:r></w:p></w:body></w:document>"#,
|
||||
"b".repeat(2 * 1024 * 1024 + 1)
|
||||
);
|
||||
docx(&root.path().join("over-limit.docx"), &xml)?;
|
||||
let mut index = SearchIndex::open(":memory:")?;
|
||||
assert_eq!(index.index_folder(root.path())?.failures, 2);
|
||||
assert_eq!(index.stats()?.content_files, 0);
|
||||
assert!(index.search("bomb.pdf", 10)?[0].extraction_error.is_some());
|
||||
assert!(index.search("over-limit.docx", 10)?[0]
|
||||
.extraction_error
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("exceeds"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_pdf_is_explicitly_unsupported() -> Result<()> {
|
||||
let root = tempfile::tempdir()?;
|
||||
let path = root.path().join("protected.pdf");
|
||||
pdf(&path)?;
|
||||
let mut document = Document::load(&path)?;
|
||||
document.trailer.set(
|
||||
"ID",
|
||||
vec![
|
||||
Object::string_literal("fixture-id"),
|
||||
Object::string_literal("fixture-id"),
|
||||
],
|
||||
);
|
||||
let state = lopdf::EncryptionState::try_from(lopdf::EncryptionVersion::V1 {
|
||||
document: &document,
|
||||
owner_password: "owner",
|
||||
user_password: "reader",
|
||||
permissions: lopdf::Permissions::all(),
|
||||
})?;
|
||||
document.encrypt(&state)?;
|
||||
document.save(&path)?;
|
||||
let mut index = SearchIndex::open(":memory:")?;
|
||||
assert_eq!(index.index_folder(root.path())?.failures, 1);
|
||||
assert!(index.search("protected.pdf", 10)?[0]
|
||||
.extraction_error
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("encrypted"));
|
||||
assert!(index.search("cancellation", 10)?.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn docx(path: &Path, xml: &str) -> Result<()> {
|
||||
let mut writer = zip::ZipWriter::new(fs::File::create(path)?);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
for (name, content) in [
|
||||
(
|
||||
"[Content_Types].xml",
|
||||
r#"<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#,
|
||||
),
|
||||
(
|
||||
"_rels/.rels",
|
||||
r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#,
|
||||
),
|
||||
("word/document.xml", xml),
|
||||
] {
|
||||
writer.start_file(name, options)?;
|
||||
writer.write_all(content.as_bytes())?;
|
||||
}
|
||||
writer.finish()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_pdf_and_docx_return_matching_locations_and_persist() -> Result<()> {
|
||||
let root = tempfile::tempdir()?;
|
||||
pdf(&root.path().join("agreement.pdf"))?;
|
||||
docx(
|
||||
&root.path().join("notes.docx"),
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Introduction</w:t></w:r></w:p><w:p/><w:p><w:r><w:t>Research & </w:t></w:r><w:r><w:t>development budget</w:t></w:r></w:p></w:body></w:document>"#,
|
||||
)?;
|
||||
let db = root.path().join("index.db");
|
||||
let mut index = SearchIndex::open(&db)?;
|
||||
assert_eq!(index.index_folder(root.path())?.indexed, 2);
|
||||
let result = index.search("cancellation", 10)?;
|
||||
assert_eq!(result[0].source.as_ref().unwrap().kind, "page");
|
||||
assert_eq!(result[0].source.as_ref().unwrap().number, 2);
|
||||
assert!(result[0].snippet.contains("cancellation"));
|
||||
let result = index.search("development", 10)?;
|
||||
assert_eq!(result[0].source.as_ref().unwrap().kind, "paragraph");
|
||||
assert_eq!(result[0].source.as_ref().unwrap().number, 3);
|
||||
assert!(result[0].snippet.contains("Research & development"));
|
||||
let batch = index.passage_batch(0, 2)?;
|
||||
assert_eq!(batch.len(), 2);
|
||||
let remainder = index.passage_batch(batch[1].id, 256)?;
|
||||
assert_eq!(remainder.len(), 2);
|
||||
assert!(remainder
|
||||
.iter()
|
||||
.any(|passage| passage.source.kind == "paragraph" && passage.source.number == 3));
|
||||
assert!(index.contains_path(&result[0].path)?);
|
||||
assert!(!index.contains_path("unknown")?);
|
||||
drop(index);
|
||||
let index = SearchIndex::open(&db)?;
|
||||
assert_eq!(
|
||||
index.search("budget", 10)?[0]
|
||||
.source
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.number,
|
||||
3
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_document_reports_failure_and_preserves_filename_search() -> Result<()> {
|
||||
let root = tempfile::tempdir()?;
|
||||
fs::write(root.path().join("notes.txt"), "persistent evidence")?;
|
||||
let mut index = SearchIndex::open(":memory:")?;
|
||||
index.index_folder(root.path())?;
|
||||
fs::write(root.path().join("broken.pdf"), b"not a PDF")?;
|
||||
assert_eq!(index.index_folder(root.path())?.failures, 1);
|
||||
let result = index.search("broken.pdf", 10)?;
|
||||
assert!(result[0].extraction_error.as_ref().unwrap().contains("PDF"));
|
||||
assert_eq!(index.search("evidence", 10)?.len(), 1);
|
||||
fs::remove_file(root.path().join("broken.pdf"))?;
|
||||
docx(
|
||||
&root.path().join("broken.docx"),
|
||||
"<w:document><w:p><w:t>broken</w:p>",
|
||||
)?;
|
||||
assert_eq!(index.index_folder(root.path())?.failures, 1);
|
||||
assert!(index.search("broken.docx", 10)?[0]
|
||||
.extraction_error
|
||||
.is_some());
|
||||
pdf(&root.path().join("agreement.pdf"))?;
|
||||
index.index_folder(root.path())?;
|
||||
assert_eq!(index.search("cancellation", 10)?.len(), 1);
|
||||
fs::write(root.path().join("agreement.pdf"), "corrupted replacement")?;
|
||||
assert_eq!(index.index_folder(root.path())?.failures, 1);
|
||||
assert!(index.search("cancellation", 10)?.is_empty());
|
||||
assert!(index.search("agreement.pdf", 10)?[0]
|
||||
.extraction_error
|
||||
.is_some());
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user