use crate::{storage, text, types::*, Error, Result};
use parking_lot::Mutex;
use rusqlite::Connection;
use std::sync::atomic::{AtomicBool, Ordering};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use tantivy::{collector::{Collector, DocSetCollector, SegmentCollector, TopDocs, sort_key::{SortBySimilarityScore, SortByStaticFastValue}}, columnar::Column, directory::MmapDirectory, doc, DocId, Order, Score, SegmentOrdinal, SegmentReader,
query::{AllQuery, BoostQuery, BooleanQuery, ConstScoreQuery, Occur, Query, TermQuery},
schema::{Field, IndexRecordOption, Schema, TextFieldIndexing, TextOptions, Value as TantivyValue, FAST, INDEXED, STORED},
tokenizer::WhitespaceTokenizer, Index, IndexReader, IndexWriter, ReloadPolicy, Term};
pub(crate) const FORMAT: &str = "p-memory-text-v13";
struct Fields { key: Field, namespace: Field, scope: Field, kind: Field, tags: Field, text: Field, name: Field, path: Field, note: Field, body: Field }
const NAME_FIELD_BOOST: f32 = 3.0;
const WRITER_MEMORY_BUDGET: usize = 1_000_000_000;
pub(crate) struct IndexDocument {
pub id: i64,
pub namespace_id: i64,
pub scope_id: i64,
pub kind: RecordKind,
pub text: String,
pub name: String,
pub path: String,
pub note_id: i64,
pub tags_prefix: String,
pub tag_ids: Vec<i64>,
}
pub(crate) struct IndexFilter { pub namespace: i64, pub scopes: Vec<i64>, pub kinds: Vec<i64>, pub tags: Vec<i64>, pub note_ids: Vec<i64> }
pub(crate) struct TextIndex {
reader: IndexReader, writer: Mutex<IndexWriter>, fields: Fields,
dirty: AtomicBool,
#[cfg(test)] pub(crate) fail_search: AtomicBool,
}
impl TextIndex {
pub fn open(root: &Path) -> Result<Self> {
let directory = root.join("text-v2");
std::fs::create_dir_all(&directory)?;
let mut builder = Schema::builder();
let tokenized = || TextOptions::default().set_indexing_options(TextFieldIndexing::default()
.set_tokenizer("pretokenized").set_index_option(IndexRecordOption::WithFreqsAndPositions));
let fields = Fields {
key: builder.add_u64_field("key", INDEXED | STORED | FAST),
namespace: builder.add_u64_field("namespace", INDEXED),
scope: builder.add_u64_field("scope", INDEXED),
kind: builder.add_u64_field("kind", INDEXED),
tags: builder.add_u64_field("tags", INDEXED),
text: builder.add_text_field("text", tokenized()),
name: builder.add_text_field("name", tokenized()),
path: builder.add_text_field("path", tokenized()),
note: builder.add_u64_field("note", INDEXED | FAST),
body: builder.add_text_field("body", STORED),
};
let schema = builder.build();
let open = || -> Result<Index> {
let dir = MmapDirectory::open(&directory).map_err(|e| Error::Index(e.to_string()))?;
Ok(Index::open_or_create(dir, schema.clone())?)
};
let index = match open() {
Ok(index) => index,
Err(_) => {
std::fs::rename(&directory, root.join(format!("text-v2.corrupt-{}", uuid::Uuid::new_v4())))?;
std::fs::create_dir(&directory)?;
open()?
}
};
index.tokenizers().register("pretokenized", WhitespaceTokenizer::default());
let writer = index.writer_with_num_threads(1, WRITER_MEMORY_BUDGET)?;
let reader = index.reader_builder().reload_policy(ReloadPolicy::Manual).try_into()?;
Ok(Self { reader, writer: Mutex::new(writer), fields,
dirty: AtomicBool::new(false),
#[cfg(test)] fail_search: AtomicBool::new(false) })
}
pub fn document_count(&self) -> usize { self.reader.searcher().num_docs() as usize }
fn indexed_ids(&self) -> Result<HashSet<i64>> {
let searcher = self.reader.searcher();
let mut columns: HashMap<u32, Column<u64>> = HashMap::new();
let mut out = HashSet::new();
for address in searcher.search(&AllQuery, &DocSetCollector)? {
if !columns.contains_key(&address.segment_ord) {
let column = searcher.segment_reader(address.segment_ord).fast_fields().u64("key")?;
columns.insert(address.segment_ord, column);
}
if let Some(value) = columns[&address.segment_ord].first(address.doc_id) { out.insert(value as i64); }
}
Ok(out)
}
pub fn reconcile(&self, conn: &Connection) -> Result<()> {
let indexed = self.indexed_ids()?;
let live: HashMap<i64, (i64, i64, i64, String)> = {
let mut stmt = conn.prepare("SELECT id,namespace_id,kind,scope_id,payload_json FROM records WHERE kind<>?1")?;
let rows = stmt.query_map([RecordKind::Note.code()], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?, r.get::<_, i64>(3)?, r.get::<_, String>(4)?)))?;
let mut map = HashMap::new();
for row in rows { let (id, ns, kind, scope, payload) = row?; map.insert(id, (ns, kind, scope, payload)); }
map
};
let removals: Vec<i64> = indexed.iter().filter(|id| !live.contains_key(id)).copied().collect();
let additions: Vec<i64> = live.keys().filter(|id| !indexed.contains(id)).copied().collect();
if removals.is_empty() && additions.is_empty() { return Ok(()); }
let mut writer = self.writer.lock();
for id in removals { writer.delete_term(Term::from_field_u64(self.fields.key, id as u64)); }
let mut files: HashMap<i64, Vec<String>> = HashMap::new();
let mut paths: HashMap<i64, (Vec<String>, String)> = HashMap::new();
for id in additions {
let Some((namespace_id, kind_code, scope_id, payload_json)) = live.get(&id).cloned() else { continue };
let Some(kind) = RecordKind::from_code(kind_code) else { continue };
if let Some(item) = self.document_for(&mut files, &mut paths, id, namespace_id, scope_id, kind, &payload_json, conn) {
self.add(&mut writer, &item)?;
}
}
self.finish(&mut writer, conn, storage::current_revision(conn)?)
}
pub fn stage(&self, docs: &[IndexDocument]) -> Result<()> {
if docs.is_empty() { return Ok(()); }
let mut writer = self.writer.lock();
for item in docs { self.add(&mut writer, item)?; }
self.dirty.store(true, Ordering::SeqCst);
Ok(())
}
fn add(&self, writer: &mut IndexWriter, item: &IndexDocument) -> Result<()> {
writer.delete_term(Term::from_field_u64(self.fields.key, item.id as u64));
let mut document = doc!(self.fields.body => item.text.clone());
document.add_u64(self.fields.key, item.id as u64);
document.add_u64(self.fields.namespace, item.namespace_id as u64);
document.add_u64(self.fields.scope, item.scope_id as u64);
document.add_u64(self.fields.kind, item.kind.code() as u64);
for tag_id in &item.tag_ids { document.add_u64(self.fields.tags, *tag_id as u64); }
let searchable = if item.tags_prefix.is_empty() { item.text.clone() }
else { format!("{}\n{}", item.tags_prefix, item.text) };
let cleaned = text::clean_markdown(&searchable);
if !cleaned.is_empty() { document.add_text(self.fields.text, text::tokenize(&cleaned).join(" ")); }
if !item.name.is_empty() { document.add_text(self.fields.name, text::tokenize(&item.name).join(" ")); }
if !item.path.is_empty() { document.add_text(self.fields.path, text::tokenize(&item.path).join(" ")); }
if item.note_id != 0 { document.add_u64(self.fields.note, item.note_id as u64); }
writer.add_document(document)?;
Ok(())
}
fn finish(&self, writer: &mut IndexWriter, conn: &Connection, revision: i64) -> Result<()> {
let mut prepared = writer.prepare_commit()?;
prepared.set_payload(&format!("{FORMAT}:{revision}"));
prepared.commit()?;
self.reader.reload()?;
conn.execute("UPDATE meta SET value=?1 WHERE key='indexed_revision' AND value<?1", [revision])?;
Ok(())
}
fn is_dirty(&self) -> bool { self.dirty.load(Ordering::SeqCst) }
pub fn stage_deletions(&self, ids: &[i64]) -> Result<()> {
if ids.is_empty() { return Ok(()); }
let writer = self.writer.lock();
for id in ids { writer.delete_term(Term::from_field_u64(self.fields.key, *id as u64)); }
self.dirty.store(true, Ordering::SeqCst);
Ok(())
}
pub fn commit_staged(&self, conn: &Connection) -> Result<()> {
let mut writer = self.writer.lock();
if !self.is_dirty() { return Ok(()); }
self.finish(&mut writer, conn, storage::current_revision(conn)?)?;
self.dirty.store(false, Ordering::SeqCst);
Ok(())
}
pub fn stage_records(&self, conn: &Connection, ids: &[i64]) -> Result<()> {
if ids.is_empty() { return Ok(()); }
let placeholders = vec!["?"; ids.len()].join(",");
let sql = format!("SELECT id,namespace_id,kind,scope_id,payload_json FROM records \
WHERE id IN ({placeholders}) AND kind<>?");
let mut values: Vec<rusqlite::types::Value> = ids.iter().map(|id| rusqlite::types::Value::Integer(*id)).collect();
values.push(rusqlite::types::Value::Integer(RecordKind::Note.code()));
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query(rusqlite::params_from_iter(values.iter()))?;
let mut documents = Vec::new();
let mut files: HashMap<i64, Vec<String>> = HashMap::new();
let mut paths: HashMap<i64, (Vec<String>, String)> = HashMap::new();
while let Some(row) = rows.next()? {
let (id, namespace_id, kind_code, scope_id, payload_json) =
(row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?, row.get::<_, i64>(3)?, row.get::<_, String>(4)?);
let Some(kind) = RecordKind::from_code(kind_code) else { continue };
if let Some(item) = self.document_for(&mut files, &mut paths, id, namespace_id, scope_id, kind, &payload_json, conn) {
documents.push(item);
}
}
self.stage(&documents)
}
pub fn sync(&self, conn: &Connection) -> Result<()> {
self.commit_staged(conn)
}
fn document_for(&self, files: &mut HashMap<i64, Vec<String>>, paths: &mut HashMap<i64, (Vec<String>, String)>,
id: i64, namespace_id: i64, scope_id: i64, kind: RecordKind, payload_json: &str, conn: &Connection) -> Option<IndexDocument> {
let payload = serde_json::from_str::<serde_json::Value>(payload_json).ok()?;
let ordinal = payload.get("ordinal").and_then(|v| v.as_u64()).unwrap_or(0);
let text = match kind {
RecordKind::Chunk => {
let note_id = payload.get("note_id").and_then(|v| v.as_i64()).unwrap_or(0);
if !files.contains_key(¬e_id) { files.insert(note_id, self.note_file_chunks(conn, note_id)); }
files.get(¬e_id).and_then(|chunks| chunks.get(ordinal as usize)).cloned().unwrap_or_default()
}
_ => storage::record_text(kind, &payload),
};
let pairs = storage::record_tag_pairs(conn, id).unwrap_or_default();
let tags: Vec<String> = pairs.iter().map(|(_, tag)| tag.clone()).collect();
let (name, path, exclude) = match kind {
RecordKind::Chunk if ordinal == 0 => {
let note_id = payload.get("note_id").and_then(|v| v.as_i64()).unwrap_or(0);
let (dirs, stem) = paths.entry(note_id).or_insert_with(|| storage::note_path_parts(conn, note_id)).clone();
let mut exclude = dirs.clone();
if !stem.is_empty() { exclude.push(stem.clone()); }
(stem, dirs.join(" "), exclude)
}
RecordKind::Chunk => (String::new(), String::new(), Vec::new()),
_ => (storage::record_name(kind, &payload), String::new(), Vec::new()),
};
Some(IndexDocument { id, namespace_id, scope_id, kind, text, name, path,
note_id: if kind == RecordKind::Chunk { payload.get("note_id").and_then(|v| v.as_i64()).unwrap_or(0) } else { 0 },
tags_prefix: storage::tags_prefix(kind, &tags, &exclude, &payload),
tag_ids: pairs.into_iter().map(|(tag_id, _)| tag_id).collect() })
}
fn note_file_chunks(&self, conn: &Connection, note_id: i64) -> Vec<String> {
let Ok((path, namespace_id)) = conn.query_row("SELECT n.path,n.namespace_id FROM notes n WHERE n.record_id=?1",
[note_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))) else { return Vec::new() };
let Ok(content) = std::fs::read_to_string(storage::absolute_note_path(conn, namespace_id, &path)) else { return Vec::new() };
let chunk_chars = conn.query_row("SELECT payload_json FROM records WHERE id=?1", [note_id], |r| r.get::<_, String>(0))
.ok().and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
.and_then(|value| value.get("chunk_chars").and_then(|v| v.as_u64()))
.unwrap_or(220) as usize;
crate::notes::chunk_text(&content, chunk_chars).map(|chunks| chunks.into_iter().map(|chunk| chunk.content).collect()).unwrap_or_default()
}
fn exact_u64(field: Field, value: i64) -> Box<dyn Query> {
Box::new(TermQuery::new(Term::from_field_u64(field, value as u64), IndexRecordOption::Basic))
}
fn term(field: Field, value: &str, occurrence: Occur) -> (Occur, Box<dyn Query>) {
(occurrence, Box::new(TermQuery::new(Term::from_field_text(field, value), IndexRecordOption::WithFreqs)) as Box<dyn Query>)
}
fn filtered_query(&self, tokens: &[String], strict: bool, filter: &IndexFilter, field: MatchField) -> Box<dyn Query> {
let occurrence = if strict { Occur::Must } else { Occur::Should };
let hit = BooleanQuery::new(tokens.iter().map(|token| {
let column: Box<dyn Query> = match field {
MatchField::All => Box::new(BooleanQuery::new(vec![
Self::term(self.fields.text, token, Occur::Should),
Self::term(self.fields.name, token, Occur::Should),
])),
MatchField::Text => Box::new(TermQuery::new(Term::from_field_text(self.fields.text, token), IndexRecordOption::WithFreqs)),
MatchField::Name => Box::new(TermQuery::new(Term::from_field_text(self.fields.name, token), IndexRecordOption::WithFreqs)),
MatchField::Path => Box::new(TermQuery::new(Term::from_field_text(self.fields.path, token), IndexRecordOption::WithFreqs)),
};
(occurrence, column)
}).collect());
let relevance: Box<dyn Query> = if field == MatchField::All {
let name_relevance = BooleanQuery::new(tokens.iter().map(|token| Self::term(self.fields.name, token, Occur::Should)).collect());
Box::new(BooleanQuery::new(vec![
(Occur::Must, Box::new(hit) as Box<dyn Query>),
(Occur::Should, Box::new(BoostQuery::new(Box::new(name_relevance), NAME_FIELD_BOOST)) as Box<dyn Query>),
]))
} else {
Box::new(hit)
};
let mut clauses: Vec<(Occur, Box<dyn Query>)> = vec![(Occur::Must, Box::new(relevance))];
let scopes = BooleanQuery::new(filter.scopes.iter().map(|scope| (Occur::Should, Self::exact_u64(self.fields.scope, *scope))).collect());
clauses.push((Occur::Must, Box::new(ConstScoreQuery::new(Self::exact_u64(self.fields.namespace, filter.namespace), 0.0))));
clauses.push((Occur::Must, Box::new(ConstScoreQuery::new(Box::new(scopes), 0.0))));
if !filter.kinds.is_empty() {
let kinds = BooleanQuery::new(filter.kinds.iter().map(|kind| (Occur::Should, Self::exact_u64(self.fields.kind, *kind))).collect());
clauses.push((Occur::Must, Box::new(ConstScoreQuery::new(Box::new(kinds), 0.0))));
}
for tag_id in &filter.tags {
clauses.push((Occur::Must, Box::new(ConstScoreQuery::new(Self::exact_u64(self.fields.tags, *tag_id), 0.0))));
}
if !filter.note_ids.is_empty() {
let notes = BooleanQuery::new(filter.note_ids.iter().map(|id| (Occur::Should, Self::exact_u64(self.fields.note, *id))).collect());
clauses.push((Occur::Must, Box::new(ConstScoreQuery::new(Box::new(notes), 0.0))));
}
Box::new(BooleanQuery::new(clauses))
}
pub fn search_in(&self, query: &str, filter: &IndexFilter, limit: usize, field: MatchField) -> Result<Vec<(RecordKey, f64)>> {
#[cfg(test)]
if self.fail_search.load(Ordering::SeqCst) { return Err(Error::Index("injected index failure".into())); }
let mut result = Vec::new();
let mut seen = HashSet::new();
let searcher = self.reader.searcher();
let mut strict_rank = HashMap::new();
let mut key_columns: HashMap<u32, tantivy::columnar::Column<u64>> = HashMap::new();
for strict in [true, false] {
if !strict && result.len() >= limit { break; }
let tokens = text::query_terms(query, strict);
if tokens.is_empty() { continue; }
let query = self.filtered_query(&tokens, strict, filter, field);
let collector = TopDocs::with_limit(limit).order_by(((SortBySimilarityScore, Order::Desc), (SortByStaticFastValue::<u64>::for_field("key"), Order::Asc)));
let hits = searcher.search(&*query, &collector)?;
for ((score, _), address) in hits {
if !key_columns.contains_key(&address.segment_ord) {
let column = searcher.segment_reader(address.segment_ord).fast_fields().u64("key")?;
key_columns.insert(address.segment_ord, column);
}
let Some(value) = key_columns[&address.segment_ord].first(address.doc_id) else { continue };
let key = RecordKey { id: value as i64 };
if !seen.insert(key) { continue; }
strict_rank.insert(key, strict);
result.push((key, score as f64));
}
}
result.sort_by(|a, b| strict_rank[&b.0].cmp(&strict_rank[&a.0]).then_with(|| b.1.total_cmp(&a.1)).then_with(|| a.0.cmp(&b.0)));
result.truncate(limit);
Ok(result)
}
pub fn count_in_many(&self, query: &str, filter: &IndexFilter, field: MatchField, note_ids: &[i64]) -> Result<HashMap<i64, usize>> {
let mut out: HashMap<i64, usize> = note_ids.iter().map(|id| (*id, 0)).collect();
if out.is_empty() { return Ok(out); }
let tokens = text::query_terms(query, false);
if tokens.is_empty() { return Ok(out); }
let base = self.filtered_query(&tokens, false, filter, field);
let notes = BooleanQuery::new(note_ids.iter().map(|id| (Occur::Should, Self::exact_u64(self.fields.note, *id))).collect());
let scoped = BooleanQuery::new(vec![(Occur::Must, base), (Occur::Must, Box::new(notes))]);
let collector = NoteCountCollector { targets: Arc::new(note_ids.iter().copied().collect()) };
for (note, count) in self.reader.searcher().search(&scoped, &collector)? {
if let Some(slot) = out.get_mut(¬e) { *slot = count; }
}
Ok(out)
}
pub fn bodies(&self, ids: &[i64]) -> Result<BTreeMap<i64, String>> {
let mut out = BTreeMap::new();
if ids.is_empty() { return Ok(out); }
let searcher = self.reader.searcher();
let query = BooleanQuery::new(ids.iter().map(|id| (Occur::Should, Self::exact_u64(self.fields.key, *id))).collect());
for address in searcher.search(&query, &DocSetCollector)? {
let document: tantivy::TantivyDocument = searcher.doc(address)?;
let key = document.get_first(self.fields.key).and_then(|value| value.as_u64());
let body = document.get_first(self.fields.body).and_then(|value| value.as_str());
if let (Some(key), Some(body)) = (key, body) { out.insert(key as i64, body.to_string()); }
}
Ok(out)
}
}
struct NoteCountCollector {
targets: Arc<HashSet<i64>>,
}
struct NoteCountChild {
column: Column<u64>,
targets: Arc<HashSet<i64>>,
counts: HashMap<i64, usize>,
}
impl Collector for NoteCountCollector {
type Fruit = HashMap<i64, usize>;
type Child = NoteCountChild;
fn for_segment(&self, _segment_ord: SegmentOrdinal, segment: &SegmentReader) -> tantivy::Result<Self::Child> {
Ok(NoteCountChild {
column: segment.fast_fields().u64("note")?,
targets: self.targets.clone(),
counts: HashMap::new(),
})
}
fn requires_scoring(&self) -> bool { false }
fn merge_fruits(&self, segment_fruits: Vec<HashMap<i64, usize>>) -> tantivy::Result<HashMap<i64, usize>> {
let mut out = HashMap::new();
for fruit in segment_fruits {
for (note, count) in fruit { *out.entry(note).or_insert(0) += count; }
}
Ok(out)
}
}
impl SegmentCollector for NoteCountChild {
type Fruit = HashMap<i64, usize>;
fn collect(&mut self, doc: DocId, _score: Score) {
if let Some(value) = self.column.first(doc) {
let note = value as i64;
if self.targets.contains(¬e) { *self.counts.entry(note).or_insert(0) += 1; }
}
}
fn harvest(self) -> HashMap<i64, usize> { self.counts }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{KnowledgeBase, MemoryInput, SearchRequest};
fn seed(kb: &KnowledgeBase, n: usize) {
for i in 0..n { kb.memories().upsert(MemoryInput::new(&format!("对账测试条目{i}"))).unwrap(); }
kb.update_index().unwrap();
}
fn all_ids(kb: &KnowledgeBase) -> Vec<i64> {
kb.memories().list(&crate::PageRequest { limit: 1000, ..Default::default() }).unwrap().items.iter().map(|m| m.header.id).collect()
}
#[test]
fn delete_removes_exactly_the_matching_documents() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
seed(&kb, 5);
let index = kb.index().unwrap();
assert_eq!(index.indexed_ids().unwrap().len(), 5);
let ids = all_ids(&kb);
kb.memories().delete(&[ids[0], ids[2]], &crate::ReadFilter::default()).unwrap();
kb.update_index().unwrap();
let remaining = index.indexed_ids().unwrap();
assert_eq!(remaining.len(), 3, "提交后索引里恰好多出的那两条被摘掉");
assert!(!remaining.contains(&ids[0]) && !remaining.contains(&ids[2]));
assert!(remaining.contains(&ids[1]) && !remaining.contains(&ids[2]) && remaining.contains(&ids[4]));
}
#[test]
fn reopen_does_not_reconcile_and_explicit_reconcile_only_touched_the_orphan() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
seed(&kb, 4);
let ids = all_ids(&kb);
kb.memories().delete(&[ids[1]], &crate::ReadFilter::default()).unwrap();
drop(kb);
let kb = KnowledgeBase::open(dir.path()).unwrap();
let remaining = kb.index().unwrap().indexed_ids().unwrap();
assert_eq!(remaining.len(), 4, "重开库不做自动对账,孤儿 doc 原样留着");
kb.reconcile_index().unwrap();
let remaining = kb.index().unwrap().indexed_ids().unwrap();
assert_eq!(remaining.len(), 3, "显式对账只摘掉已删那一条");
assert!(remaining.contains(&ids[0]) && remaining.contains(&ids[2]) && remaining.contains(&ids[3]));
}
#[test]
fn reconcile_restores_missing_documents() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
seed(&kb, 6);
drop(kb);
std::fs::remove_dir_all(dir.path().join("text-v2")).unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
assert_eq!(kb.index().unwrap().indexed_ids().unwrap().len(), 0, "重开库不自动补缺");
kb.reconcile_index().unwrap();
assert_eq!(kb.index().unwrap().indexed_ids().unwrap().len(), 6, "缺的文档应被显式对账补回");
let request = SearchRequest { query: "对账测试条目3".into(), kinds: vec![RecordKind::Memory],
vector: false, rerank: false, ..Default::default() };
assert!(!kb.search(&request).unwrap().hits.is_empty(), "补回的文档应可检索");
}
}