use p_memory::*;
use p_memory::graph::{EntityInput, EventInput, RelationInput};
use p_memory::notes::chunk_text;
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
fn memory(text: &str, scope: &str) -> MemoryInput {
let mut value = MemoryInput::new(text);
value.record.scope = scope.into(); value
}
fn entity(name: &str) -> EntityInput {
EntityInput { record: RecordInput::default(), name: name.into(), entity_type: "person".into(),
aliases: vec![], attributes: BTreeMap::new(), summary: String::new() }
}
fn fallback_vector(text: &str, dimension: usize) -> Vec<f32> {
let mut values = vec![0f32; dimension];
for (index, byte) in text.bytes().enumerate() {
values[(index + usize::from(byte)) % dimension] += 1.0 + f32::from(byte % 7) * 0.1;
}
if values.iter().all(|value| *value == 0.0) { values[0] = 1.0; }
values
}
fn attempt_error() -> EmbedCallbackError { EmbedCallbackError::other("boom") }
struct FakeEmbedder {
dimension: usize,
table: Arc<Mutex<BTreeMap<String, Vec<f32>>>>,
calls: Arc<Mutex<Vec<(String, usize)>>>,
}
impl FakeEmbedder {
fn new(dimension: usize) -> Self {
Self { dimension, table: Arc::new(Mutex::new(BTreeMap::new())), calls: Arc::new(Mutex::new(Vec::new())) }
}
fn with_table(dimension: usize, entries: &[(&str, Vec<f32>)]) -> Self {
let embedder = Self::new(dimension);
{
let mut table = embedder.table.lock().unwrap();
for (text, vector) in entries { table.insert((*text).to_string(), vector.clone()); }
}
embedder
}
fn lengths(&self) -> Arc<Mutex<Vec<(String, usize)>>> { self.calls.clone() }
}
impl Embedder for FakeEmbedder {
fn embed(&mut self, texts: &[String]) -> std::result::Result<Vec<Vec<f32>>, EmbedCallbackError> {
let caller = std::thread::current().name().unwrap_or_default().to_string();
self.calls.lock().unwrap().push((caller, texts.len()));
if texts.iter().any(|text| text.contains("触发降级")) { return Err(attempt_error()); }
let table = self.table.lock().unwrap();
Ok(texts.iter().map(|text| table.get(text).cloned().unwrap_or_else(|| fallback_vector(text, self.dimension))).collect())
}
}
fn space(kb: &KnowledgeBase, id: &str, dimension: usize) {
kb.embeddings().register_space(EmbeddingSpace { id: id.into(), model: "fixture/v1".into(), dimension, text_version: 1, encoding: "f32".into() }).unwrap();
kb.embeddings().register_embedder(id, FakeEmbedder::new(dimension)).unwrap();
}
fn fill(kb: &KnowledgeBase, space_id: &str) -> SyncReport { kb.embeddings().sync(space_id, 32).unwrap().value }
fn wait_until_ready(kb: &KnowledgeBase, namespace: &str, space_id: &str, target: &str, budget_ms: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(budget_ms);
while std::time::Instant::now() < deadline {
if kb.embeddings().vector_ready(namespace, space_id, target).unwrap() { return true; }
std::thread::sleep(std::time::Duration::from_millis(25));
}
false
}
fn calls_from(calls: &Arc<Mutex<Vec<(String, usize)>>>, thread: &str) -> usize {
calls.lock().unwrap().iter().filter(|(caller, _)| caller == thread).count()
}
fn vector_query(space_id: &str, query: &str, kinds: Vec<RecordKind>) -> SearchRequest {
SearchRequest { query: query.into(), kinds, embed_space: Some(space_id.into()), text: false, ..Default::default() }
}
#[test]
fn transactions_scopes_pagination_and_persistence() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
assert!(matches!(KnowledgeBase::open(dir.path()), Err(Error::Locked(_))));
let mut first = memory("上海 茶 Rust API", "public");
first.record.tags = vec![" RUST ".into(), "rust".into()];
let receipt = kb.memories().upsert(first.clone()).unwrap();
let a = receipt.value.header.id;
let b = kb.memories().upsert(memory("其他内容", "public")).unwrap().value.header.id;
let c = kb.memories().upsert(memory("上海 茶", "private")).unwrap().value.header.id;
let mut page = PageRequest { limit: 1, ..Default::default() };
let result = kb.memories().list(&page).unwrap();
assert_eq!(result.items[0].header.id, a); page.after = result.next_cursor;
assert_eq!(kb.memories().list(&page).unwrap().items[0].header.id, b);
assert!(matches!(kb.memories().get(c, &ReadFilter::default()), Err(Error::NotFound(_))));
let mut moved = memory("moved", "private"); moved.record.id = Some(a);
assert!(matches!(kb.memories().upsert(moved), Err(Error::Conflict(_))));
let revision = kb.health().unwrap().revision;
assert!(kb.memories().upsert_many(&[memory("valid", "public"), MemoryInput::new(" ")]).is_err());
assert_eq!(kb.health().unwrap().revision, revision);
first.record.expected_revision = Some(0); first.record.id = Some(a);
assert!(matches!(kb.memories().upsert(first), Err(Error::StaleRevision(_))));
let req = SearchRequest { query: "api".into(), filter: ReadFilter { tags: vec!["rust".into()], ..Default::default() }, ..Default::default() };
assert_eq!(kb.search(&req).unwrap().hits[0].key.id, a);
let clone = kb.clone(); kb.close().unwrap();
assert!(matches!(clone.health(), Err(Error::Closed)));
let kb = KnowledgeBase::open(dir.path()).unwrap();
assert_eq!(kb.search(&req).unwrap().hits[0].key.id, a);
let health = kb.health().unwrap();
assert_eq!(health.sqlite_integrity, "ok"); assert_eq!(health.foreign_key_errors, 0);
assert_eq!(health.record_count, health.index_document_count);
}
#[test]
fn registration_binds_validates_and_unbinds() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
let embeddings = kb.embeddings();
assert!(matches!(embeddings.register_embedder("ghost", FakeEmbedder::new(2)), Err(Error::NotFound(_))));
embeddings.register_space(EmbeddingSpace { id: "v".into(), model: "fixture/v1".into(), dimension: 4, text_version: 1, encoding: "f32".into() }).unwrap();
let wrong = embeddings.register_embedder("v", FakeEmbedder::new(3)).unwrap_err();
assert!(matches!(wrong, Error::InvalidVector(_)), "{wrong}");
assert!(wrong.to_string().contains("expected dimension 4"), "{wrong}");
assert!(!kb.health().unwrap().embedder_spaces.contains(&"v".to_string()));
assert!(embeddings.register_embedder("v", |texts: &[String]| Ok(vec![vec![1.0f32, 0.0, 0.0, 0.0]; texts.len() - 1])).is_err());
assert!(embeddings.register_embedder("v", |texts: &[String]| Ok(vec![vec![f32::NAN; 4]; texts.len()])).is_err());
assert!(embeddings.register_embedder("v", |texts: &[String]| Ok(vec![vec![0.0f32; 4]; texts.len()])).is_err());
embeddings.register_embedder("v", FakeEmbedder::new(4)).unwrap();
assert_eq!(embeddings.embedder_space("v").unwrap().unwrap().dimension, 4);
assert_eq!(kb.health().unwrap().embedder_spaces, vec!["v".to_string()]);
assert!(embeddings.unregister_embedder("v").unwrap());
assert!(!embeddings.unregister_embedder("v").unwrap());
assert!(kb.health().unwrap().embedder_spaces.is_empty());
assert!(embeddings.embedder_space("ghost").unwrap().is_none());
}
#[test]
fn sync_fills_missing_vectors_incrementally_and_aborts_on_failure() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
for i in 0..5 { kb.memories().upsert(memory(&format!("待向量化 {i}"), "public")).unwrap(); }
kb.embeddings().register_space(EmbeddingSpace { id: "v".into(), model: "fixture/v1".into(), dimension: 4, text_version: 1, encoding: "f32".into() }).unwrap();
assert!(matches!(kb.embeddings().sync("v", 4), Err(Error::Validation(_))));
let embedder = FakeEmbedder::new(4);
let lengths = embedder.lengths();
kb.embeddings().register_embedder_with("v", embedder, EmbedderOptions { max_batch: 4, max_tokens_per_text: None }).unwrap();
assert!(wait_until_ready(&kb, "default", "v", "memory", 15_000));
assert!(lengths.lock().unwrap().iter().all(|(_, length)| *length <= 4), "每批不得超过声明的 max_batch");
let calls_after_fill = lengths.lock().unwrap().len();
assert_eq!(kb.embeddings().sync("v", 32).unwrap().value.written, 0);
assert_eq!(lengths.lock().unwrap().len(), calls_after_fill);
let hits = kb.search(&vector_query("v", "待向量化 1", vec![RecordKind::Memory])).unwrap();
assert!(hits.diagnostics.vector_used && !hits.diagnostics.text_used, "向量路自行嵌入查询词");
assert!(hits.hits.iter().any(|hit| hit.record["judgment"] == json!("待向量化 1")), "查询词命中它对应的记录");
}
#[test]
fn sync_stops_after_the_first_failing_batch() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.embeddings().register_space(EmbeddingSpace { id: "v".into(), model: "fixture/v1".into(), dimension: 4, text_version: 1, encoding: "f32".into() }).unwrap();
kb.memories().upsert(memory("第一批 甲", "public")).unwrap();
kb.memories().upsert(memory("第一批 乙", "public")).unwrap();
kb.memories().upsert(memory("第二批 丙", "public")).unwrap();
kb.embeddings().register_embedder_with("v", |texts: &[String]| {
if texts.iter().any(|text| text.contains("第二批")) { return Err(attempt_error()); }
Ok(texts.iter().map(|text| fallback_vector(text, 4)).collect())
}, EmbedderOptions { max_batch: 2, max_tokens_per_text: None }).unwrap();
assert!(!wait_until_ready(&kb, "default", "v", "memory", 1_000), "补不上的记录让记忆档停在未就绪");
assert!(!kb.embeddings().vector_ready("default", "v", "memory").unwrap());
let gated = kb.search(&vector_query("v", "甲", vec![RecordKind::Memory])).unwrap();
assert!(gated.hits.is_empty() && gated.diagnostics.degraded.contains(&Degrade::VectorNotReady));
let settled = kb.embeddings().sync("v", 2).unwrap().value;
assert_eq!(settled.written, 0);
assert!(settled.interrupted.is_some());
kb.embeddings().register_embedder("v", FakeEmbedder::new(4)).unwrap();
assert!(wait_until_ready(&kb, "default", "v", "memory", 15_000), "换成能用的回调后补完即就绪");
let mut requests = vector_query("v", "甲", vec![RecordKind::Memory]);
requests.limit = 10;
assert_eq!(kb.search(&requests).unwrap().hits.len(), 3, "三条都补上了");
}
#[test]
fn writes_stay_clean_and_the_batch_end_sync_fills_vectors() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
kb.embeddings().register_space(EmbeddingSpace { id: "v".into(), model: "fixture/v1".into(), dimension: 4, text_version: 1, encoding: "f32".into() }).unwrap();
let mut tagged = memory("记住她喜欢苹果", "public");
tagged.record.tags = vec!["偏好".into()];
let id = kb.memories().upsert(tagged).unwrap().value.header.id;
assert!(!kb.embeddings().vector_ready("default", "v", "memory").unwrap(), "还没补过,谈不上就绪");
let gated = kb.search(&vector_query("v", "记住她喜欢苹果", vec![RecordKind::Memory])).unwrap();
assert!(gated.hits.is_empty() && gated.diagnostics.degraded.contains(&Degrade::VectorNotReady),
"记忆档还没补齐时它的向量不参与打分");
let embedder = FakeEmbedder::new(4);
let calls = embedder.lengths();
let me = std::thread::current().name().unwrap_or_default().to_string();
kb.embeddings().register_embedder("v", embedder).unwrap();
let after_registration = calls_from(&calls, &me);
kb.memories().upsert(memory("第二条 也喜欢梨", "public")).unwrap();
assert_eq!(calls_from(&calls, &me), after_registration, "写入不许碰模型");
assert!(wait_until_ready(&kb, "default", "v", "memory", 15_000), "补完记忆档才放行向量路");
let hits = kb.search(&vector_query("v", "记住她喜欢苹果", vec![RecordKind::Memory])).unwrap().hits;
assert_eq!(hits[0].key.id, id);
let by_tag = kb.search(&SearchRequest { text: false, embed_space: Some("v".into()),
kinds: vec![RecordKind::Memory], ..SearchRequest { query: "偏好".into(), ..Default::default() } }).unwrap().hits;
assert!(by_tag.iter().any(|hit| hit.key.id == id), "只出现在标签里的词也能召回");
let note_path = dir.path().join("a.md");
std::fs::write(¬e_path, "笔记正文里的独有措辞").unwrap();
let note = kb.notes().upsert_file(NoteFileInput::new(¬e_path)).unwrap().value;
kb.update_index().unwrap();
let vector_hits = kb.search(&vector_query("v", "笔记正文里的独有措辞", vec![RecordKind::Note, RecordKind::Chunk])).unwrap().hits;
assert!(vector_hits.is_empty(), "笔记默认不应生成向量");
let text_hits = kb.search(&SearchRequest { query: "独有措辞".into(), kinds: vec![RecordKind::Note, RecordKind::Chunk], ..Default::default() }).unwrap().hits;
let chunk_id = kb.notes().chunks(note.header.id, &ReadFilter::default()).unwrap()[0].header.id;
assert!(text_hits.iter().any(|hit| hit.key.id == chunk_id), "正文命中应落在切片上");
assert_eq!(kb.notes().get_chunk(chunk_id, &ReadFilter::default()).unwrap().content, "笔记正文里的独有措辞");
assert_eq!(kb.notes().chunks(note.header.id, &ReadFilter::default()).unwrap().len(), 1);
}
#[test]
fn namespace_switch_disables_vectorization_and_degrades() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
space(&kb, "v", 4);
let mut other = memory("另一个知识域的内容", "public"); other.record.namespace = "other".into();
let other_id = kb.memories().upsert(other).unwrap().value.header.id;
kb.embeddings().set_namespace_vectorization("other", false).unwrap();
let mut muted = memory("被关闭向量化的内容", "public"); muted.record.namespace = "other".into();
kb.memories().upsert(muted).unwrap();
let request = SearchRequest { query: "被关闭".into(), filter: ReadFilter { namespace: "other".into(), ..Default::default() },
embed_space: Some("v".into()), ..Default::default() };
let result = kb.search(&request).unwrap();
assert!(result.diagnostics.degraded.contains(&Degrade::NamespaceDisabled));
assert_eq!(result.hits.len(), 1, "关掉向量化之后全文路仍然给结果");
let default_scope = SearchRequest { query: "被关闭".into(),
filter: ReadFilter { namespace: "default".into(), ..Default::default() }, embed_space: Some("v".into()), ..Default::default() };
assert!(kb.search(&default_scope).unwrap().hits.is_empty());
let mut restored = SearchRequest { ..default_scope };
restored.filter = ReadFilter { namespace: "other".into(), ..Default::default() };
kb.embeddings().set_namespace_vectorization("other", true).unwrap();
assert!(kb.embeddings().namespace_vectorization("other").unwrap());
assert_eq!(kb.embeddings().namespace_vectorization("default").unwrap(), true);
assert_eq!(kb.memories().get(other_id, &ReadFilter { namespace: "other".into(), ..Default::default() }).unwrap().judgment, "另一个知识域的内容");
}
#[test]
fn vectorization_targets_are_independent_per_namespace() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap(); space(&kb, "v", 4);
assert!(kb.embeddings().vectorization("default", "memory").unwrap());
assert!(kb.embeddings().vectorization("default", "graph").unwrap());
assert!(!kb.embeddings().vectorization("default", "notes").unwrap());
assert!(matches!(kb.embeddings().set_vectorization("default", "knowledge", true), Err(Error::Validation(_))));
assert!(matches!(kb.embeddings().vectorization("default", "文本"), Err(Error::Validation(_))));
kb.embeddings().set_vectorization("default", "memory", false).unwrap();
assert!(!kb.embeddings().vectorization("default", "memory").unwrap());
assert!(kb.embeddings().vectorization("default", "graph").unwrap());
assert!(!kb.embeddings().vectorization("default", "notes").unwrap());
let muted = kb.memories().upsert(memory("记忆档关闭时的措辞", "public")).unwrap().value.header.id;
let entity_id = kb.graph().apply_batch(&GraphBatch { entities: vec![entity("记忆档关闭时写入的实体")], ..Default::default() })
.unwrap().value.entities[0].header.id;
fill(&kb, "v");
assert!(kb.search(&vector_query("v", "记忆档关闭时的措辞", vec![RecordKind::Memory])).unwrap().hits.is_empty());
assert_eq!(kb.search(&vector_query("v", "记忆档关闭时写入的实体", vec![RecordKind::Entity])).unwrap().hits[0].key.id, entity_id);
assert_eq!(kb.embeddings().sync("v", 32).unwrap().value.written, 0, "关闭的档位不该被 sync 补出向量");
kb.embeddings().set_vectorization("default", "memory", true).unwrap();
assert!(wait_until_ready(&kb, "default", "v", "memory", 15_000), "重新打开后补齐缺的那一条");
assert_eq!(kb.search(&vector_query("v", "记忆档关闭时的措辞", vec![RecordKind::Memory])).unwrap().hits[0].key.id, muted);
}
#[test]
fn notes_switch_gates_chunk_vectors_and_keeps_existing_ones() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap(); space(&kb, "v", 4);
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
let path = dir.path().join("n.md");
std::fs::write(&path, "切片正文里的独有措辞").unwrap();
let note = kb.notes().upsert_file(NoteFileInput::new(&path)).unwrap().value;
kb.update_index().unwrap();
let chunk = kb.notes().chunks(note.header.id, &ReadFilter::default()).unwrap()[0].header.id;
assert!(kb.search(&vector_query("v", "切片正文里的独有措辞", vec![RecordKind::Chunk])).unwrap().hits.is_empty());
assert_eq!(kb.search(&SearchRequest { query: "独有措辞".into(), kinds: vec![RecordKind::Chunk], ..Default::default() }).unwrap().hits[0].key.id, chunk);
kb.embeddings().set_vectorization("default", "notes", true).unwrap();
assert!(wait_until_ready(&kb, "default", "v", "notes", 15_000));
let hits = kb.search(&vector_query("v", "切片正文里的独有措辞", vec![RecordKind::Chunk])).unwrap().hits;
assert_eq!(hits[0].key.id, chunk);
assert!((hits[0].vector_scores["v"] - 1.0).abs() < 1e-6, "查询词与切片正文逐字相同,余弦应为 1");
kb.embeddings().set_vectorization("default", "notes", false).unwrap();
assert!(kb.search(&vector_query("v", "切片正文里的独有措辞", vec![RecordKind::Chunk])).unwrap().hits.is_empty());
assert_eq!(kb.embeddings().sync("v", 32).unwrap().value.written, 0, "已有向量仍在,无需重算");
kb.embeddings().set_vectorization("default", "notes", true).unwrap();
fill(&kb, "v");
assert_eq!(kb.search(&vector_query("v", "切片正文里的独有措辞", vec![RecordKind::Chunk])).unwrap().hits[0].key.id, chunk);
}
#[test]
fn namespace_master_switch_overrides_every_target() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap(); space(&kb, "v", 4);
kb.embeddings().set_namespace_vectorization("other", false).unwrap();
assert!(kb.embeddings().vectorization("other", "memory").unwrap());
assert!(!kb.embeddings().vectorization("other", "notes").unwrap());
for target in ["memory", "graph", "notes"] { kb.embeddings().set_vectorization("other", target, true).unwrap(); }
let mut muted = memory("总闸关闭时的措辞", "public"); muted.record.namespace = "other".into();
kb.memories().upsert(muted).unwrap();
assert_eq!(kb.embeddings().sync("v", 32).unwrap().value.written, 0);
let request = SearchRequest { query: "总闸关闭".into(), filter: ReadFilter { namespace: "other".into(), ..Default::default() },
embed_space: Some("v".into()), ..Default::default() };
let result = kb.search(&request).unwrap();
assert!(result.diagnostics.degraded.contains(&Degrade::NamespaceDisabled));
assert!(!result.diagnostics.vector_used, "总闸关闭时不走向量路");
assert!(!result.hits.is_empty(), "总闸只关向量路,全文路照常给结果");
}
#[test]
fn vectorization_switches_survive_reopen() {
let dir = tempfile::tempdir().unwrap();
{
let kb = KnowledgeBase::open(dir.path()).unwrap(); space(&kb, "v", 4);
kb.embeddings().set_vectorization("default", "memory", false).unwrap();
kb.embeddings().set_vectorization("default", "notes", true).unwrap();
kb.close().unwrap();
}
let kb = KnowledgeBase::open(dir.path()).unwrap(); space(&kb, "v", 4);
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
assert!(!kb.embeddings().vectorization("default", "memory").unwrap());
assert!(kb.embeddings().vectorization("default", "notes").unwrap());
assert!(kb.embeddings().vectorization("default", "graph").unwrap(), "没设过的档位仍取内置默认");
kb.memories().upsert(memory("重开之后写入的记忆", "public")).unwrap();
std::fs::write(dir.path().join("n.md"), "重开之后写入的切片").unwrap();
kb.notes().upsert_file(NoteFileInput::new(dir.path().join("n.md"))).unwrap();
kb.update_index().unwrap();
assert!(wait_until_ready(&kb, "default", "v", "notes", 15_000), "只有笔记档那一条会被补上");
assert!(kb.search(&vector_query("v", "重开之后写入的记忆", vec![RecordKind::Memory])).unwrap().hits.is_empty());
assert!(!kb.search(&vector_query("v", "重开之后写入的切片", vec![RecordKind::Chunk])).unwrap().hits.is_empty());
}
#[test]
fn vector_cleanup_follows_record_lifecycle_not_the_switch() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap(); space(&kb, "v", 4);
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
let id = kb.memories().upsert(memory("待删除的记忆措辞", "public")).unwrap().value.header.id;
fill(&kb, "v");
assert_eq!(kb.search(&vector_query("v", "待删除的记忆措辞", vec![RecordKind::Memory])).unwrap().hits[0].key.id, id);
kb.embeddings().set_vectorization("default", "memory", false).unwrap();
kb.memories().delete(id, &ReadFilter::default()).unwrap();
assert!(kb.search(&vector_query("v", "待删除的记忆措辞", vec![RecordKind::Memory])).unwrap().hits.is_empty());
kb.embeddings().set_vectorization("default", "notes", true).unwrap();
let path = dir.path().join("n.md");
std::fs::write(&path, "第一版切片措辞").unwrap();
let note = kb.notes().upsert_file(NoteFileInput::new(&path)).unwrap().value;
kb.update_index().unwrap();
kb.embeddings().sync("v", 32).unwrap();
let first = kb.notes().chunks(note.header.id, &ReadFilter::default()).unwrap()[0].header.id;
assert_eq!(kb.search(&vector_query("v", "第一版切片措辞", vec![RecordKind::Chunk])).unwrap().hits[0].key.id, first);
kb.embeddings().set_vectorization("default", "notes", false).unwrap();
std::fs::write(&path, "第二版切片措辞").unwrap();
kb.notes().upsert_file(NoteFileInput::new(&path)).unwrap();
kb.update_index().unwrap();
let second = kb.notes().chunks(note.header.id, &ReadFilter::default()).unwrap()[0].header.id;
assert_ne!(second, first);
assert_eq!(kb.embeddings().sync("v", 32).unwrap().value.written, 0, "关闭的笔记档不补新向量");
kb.embeddings().set_vectorization("default", "notes", true).unwrap();
assert!(wait_until_ready(&kb, "default", "v", "notes", 15_000), "只有新切片需要补向量");
let mut wide = vector_query("v", "第二版切片措辞", vec![RecordKind::Chunk]); wide.limit = 10;
let hits = kb.search(&wide).unwrap().hits;
assert_eq!(hits[0].key.id, second);
assert!(hits.iter().all(|hit| hit.key.id != first), "旧切片的向量没有留下");
}
#[test]
fn background_thread_backfills_after_the_batch_ends() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.memories().upsert(memory("等模型上线的记忆", "public")).unwrap();
assert!(!kb.embeddings().vector_ready("default", "v", "memory").unwrap());
space(&kb, "v", 4);
assert!(wait_until_ready(&kb, "default", "v", "memory", 15_000), "库内线程应当把缺口补上");
let hits = kb.search(&vector_query("v", "等模型上线的记忆", vec![RecordKind::Memory])).unwrap().hits;
assert_eq!(hits.len(), 1);
kb.close().unwrap();
assert!(matches!(kb.embeddings().sync("v", 32), Err(Error::Closed)));
let reopened = KnowledgeBase::open(dir.path()).unwrap();
space(&reopened, "v", 4);
let fresh = reopened.memories().upsert(memory("重开之后写入的记忆", "public")).unwrap().value.header.id;
assert!(wait_until_ready(&reopened, "default", "v", "memory", 15_000), "写入之后线程自己会补");
let hits = reopened.search(&vector_query("v", "重开之后写入的记忆", vec![RecordKind::Memory])).unwrap().hits;
assert!(hits.iter().any(|hit| hit.key.id == fresh), "写入那条进了向量路");
reopened.close().unwrap();
}
#[test]
fn chunk_vectors_come_from_the_real_body_not_an_empty_one() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap(); space(&kb, "v", 4);
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
kb.embeddings().set_vectorization("default", "notes", true).unwrap();
let path = dir.path().join("n.md");
std::fs::write(&path, "切片正文独有措辞").unwrap();
let note = kb.notes().upsert_file(NoteFileInput::new(&path)).unwrap().value;
let chunk = kb.notes().chunks(note.header.id, &ReadFilter::default()).unwrap()[0].header.id;
assert!(wait_until_ready(&kb, "default", "v", "notes", 15_000));
let hits = kb.search(&vector_query("v", "切片正文独有措辞", vec![RecordKind::Chunk])).unwrap().hits;
assert_eq!(hits[0].key.id, chunk);
assert!((hits[0].vector_scores["v"] - 1.0).abs() < 1e-6, "向量取自切片正文本身,不是空文本");
}
#[test]
fn the_gap_counts_only_enabled_targets() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap(); space(&kb, "v", 4);
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
let path = dir.path().join("n.md");
std::fs::write(&path, "笔记正文里的措辞").unwrap();
kb.notes().upsert_file(NoteFileInput::new(&path)).unwrap();
kb.update_index().unwrap();
assert!(!kb.embeddings().vector_ready("default", "v", "notes").unwrap(), "关掉的档位谈不上就绪");
let vector_hits = kb.search(&vector_query("v", "笔记正文里的措辞", vec![RecordKind::Chunk])).unwrap().hits;
assert!(vector_hits.is_empty(), "关掉的档位一条向量都不生成");
kb.embeddings().set_vectorization("default", "notes", true).unwrap();
assert!(wait_until_ready(&kb, "default", "v", "notes", 15_000));
let hits = kb.search(&vector_query("v", "笔记正文里的措辞", vec![RecordKind::Chunk])).unwrap().hits;
assert_eq!(hits.len(), 1, "开档后才补出切片向量");
}
#[test]
fn callback_failures_still_commit_and_degrade_to_text() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.embeddings().register_space(EmbeddingSpace { id: "v".into(), model: "fixture/v1".into(), dimension: 4, text_version: 1, encoding: "f32".into() }).unwrap();
kb.embeddings().register_embedder("v", FakeEmbedder::new(4)).unwrap();
let id = kb.memories().upsert(memory("平铺直叙的记忆", "public")).unwrap().value.header.id;
assert!(wait_until_ready(&kb, "default", "v", "memory", 15_000));
assert!(kb.embeddings().vector_ready("default", "v", "memory").unwrap());
let asking = SearchRequest { query: "触发降级 平铺直叙".into(), embed_space: Some("v".into()), ..Default::default() };
let result = kb.search(&asking).unwrap();
assert!(result.diagnostics.degraded.contains(&Degrade::EmbedFailed));
assert!(!result.diagnostics.vector_used && result.diagnostics.text_used);
assert_eq!(result.hits.len(), 1);
assert!(matches!(kb.search(&SearchRequest { query: "任意".into(), embed_space: Some("ghost".into()), ..Default::default() }), Err(Error::NotFound(_))));
kb.embeddings().unregister_embedder("v").unwrap();
let degraded = kb.search(&SearchRequest { query: "平铺直叙".into(), embed_space: Some("v".into()), ..Default::default() }).unwrap();
assert!(degraded.diagnostics.degraded.contains(&Degrade::NoEmbedder));
assert_eq!(degraded.hits[0].key.id, id);
}
#[test]
fn an_interrupted_fill_leaves_the_domain_unready() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
space(&kb, "v", 4);
let id = kb.memories().upsert(memory("触发降级的记忆", "public")).unwrap().value.header.id;
assert!(!wait_until_ready(&kb, "default", "v", "memory", 1_000), "补不上的记录让记忆档停在未就绪");
assert!(kb.health().unwrap().last_degraded.contains(&Degrade::EmbedFailed), "补齐失败要记一档降级");
assert!(!kb.embeddings().vector_ready("default", "v", "memory").unwrap());
let gated = kb.search(&SearchRequest { query: "触发降级".into(), kinds: vec![RecordKind::Memory], embed_space: Some("v".into()), ..Default::default() }).unwrap();
assert!(gated.diagnostics.degraded.contains(&Degrade::VectorNotReady) && !gated.diagnostics.vector_used);
assert_eq!(gated.hits[0].key.id, id, "向量路关了,全文路照常给结果");
}
#[test]
fn search_parameters_control_paths_and_totals() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
space(&kb, "v", 4);
for i in 0..3 { kb.memories().upsert(memory(&format!("参数 目标 {i}"), "public")).unwrap(); }
kb.memories().upsert(memory("无关内容", "public")).unwrap();
let kinds = vec![RecordKind::Memory];
fill(&kb, "v");
let both = kb.search(&SearchRequest { query: "参数 目标".into(), kinds: kinds.clone(), limit: 3, embed_space: Some("v".into()), ..Default::default() }).unwrap();
assert!(both.diagnostics.text_used && both.diagnostics.vector_used);
assert_eq!(both.hits.len(), 3);
assert_eq!(both.total, None);
let text_only = kb.search(&SearchRequest { query: "参数 目标".into(), kinds: kinds.clone(), limit: 3, vector: false, ..Default::default() }).unwrap();
assert!(text_only.diagnostics.text_used && !text_only.diagnostics.vector_used);
assert_eq!(text_only.hits.len(), 3);
assert!(text_only.hits.iter().all(|hit| hit.rerank_score.is_none()));
let mut vector_only_request = vector_query("v", "参数 目标", kinds.clone());
vector_only_request.limit = 3;
let vector_only = kb.search(&vector_only_request).unwrap();
assert!(!vector_only.diagnostics.text_used && vector_only.diagnostics.vector_used);
assert_eq!(vector_only.hits.len(), 3);
assert!(kb.search(&SearchRequest { query: "参数".into(), text: false, vector: false, ..Default::default() }).is_err());
let no_space = kb.search(&SearchRequest { query: "参数 目标".into(), kinds: kinds.clone(), ..Default::default() }).unwrap();
assert!(no_space.diagnostics.text_used && !no_space.diagnostics.vector_used);
assert!(kb.search(&SearchRequest { query: " ".into(), ..Default::default() }).is_err());
let counted = kb.search(&SearchRequest { query: "参数 目标".into(), kinds: kinds.clone(), limit: 1, with_total: true, ..Default::default() }).unwrap();
assert_eq!(counted.hits.len(), 1);
assert_eq!(counted.total, Some(4));
let page = kb.memories().list(&PageRequest { filter: ReadFilter { tags: vec![], ..Default::default() }, limit: 100, ..Default::default() }).unwrap();
assert_eq!(page.items.len(), 4, "总量与同条件分页计数一致");
}
#[test]
fn reranker_reorders_candidates_and_enforces_length_limits() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
for tag in ["甲", "乙", "丙", "丁", "戊"] { kb.memories().upsert(memory(&format!("重排目标 {tag}"), "public")).unwrap(); }
let request = SearchRequest { query: "重排目标".into(), limit: 5, kinds: vec![RecordKind::Memory], vector: false, ..Default::default() };
let baseline: Vec<i64> = kb.search(&request).unwrap().hits.iter().map(|hit| hit.key.id).collect();
assert_eq!(baseline.len(), 5);
let seen: Arc<Mutex<Vec<(usize, usize)>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = seen.clone();
kb.register_reranker_with(move |_: &str, documents: &[String]| {
recorder.lock().unwrap().push((documents.len(), documents.iter().map(|d| d.chars().count()).max().unwrap_or(0)));
Ok((0..documents.len()).map(|i| i as f32).collect())
}, RerankerOptions { max_tokens_total: 10, max_tokens_per_doc: 6, ..Default::default() }).unwrap();
seen.lock().unwrap().clear();
let reranked = kb.search(&SearchRequest { rerank: true, ..request.clone() }).unwrap();
assert!(reranked.diagnostics.reranked);
assert_eq!(reranked.diagnostics.rerank_candidates, 2);
assert_eq!(reranked.diagnostics.rerank_truncated, 3);
assert_eq!(reranked.hits[0].key.id, baseline[1], "倒序给分后原来的第二名排到最前");
assert!(reranked.hits.iter().all(|hit| hit.rerank_score.is_some()));
assert_eq!(*seen.lock().unwrap(), vec![(2, 6)], "回调实收 2 条候选,累加到总预算超额为止");
assert!(kb.unregister_reranker());
let ignored = kb.search(&SearchRequest { rerank: true, ..request.clone() }).unwrap();
assert!(!ignored.diagnostics.reranked);
assert_eq!(ignored.hits.iter().map(|hit| hit.key.id).collect::<Vec<_>>(), baseline);
}
#[test]
fn reranker_stops_at_the_candidate_cap() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
for tag in ["甲", "乙", "丙", "丁", "戊"] { kb.memories().upsert(memory(&format!("条数上限 {tag}"), "public")).unwrap(); }
let request = SearchRequest { query: "条数上限".into(), limit: 5, kinds: vec![RecordKind::Memory], vector: false, ..Default::default() };
let seen: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = seen.clone();
kb.register_reranker_with(move |_: &str, documents: &[String]| {
recorder.lock().unwrap().push(documents.len());
Ok(vec![0.0f32; documents.len()])
}, RerankerOptions { max_candidates: 2, ..Default::default() }).unwrap();
seen.lock().unwrap().clear();
let capped = kb.search(&SearchRequest { rerank: true, ..request.clone() }).unwrap();
assert!(capped.diagnostics.reranked);
assert_eq!(capped.diagnostics.rerank_candidates, 2, "条数上限把候选截到 2 条");
assert_eq!(capped.diagnostics.rerank_truncated, 3);
assert_eq!(*seen.lock().unwrap(), vec![2], "回调实收 2 条候选");
assert!(kb.register_reranker_with(|_: &str, documents: &[String]| Ok(vec![0.0f32; documents.len()]),
RerankerOptions { max_candidates: 0, ..Default::default() }).is_err());
}
#[test]
fn rerank_consumes_the_merged_paths_before_fusion() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
space(&kb, "v", 8);
let texts = ["重排合并 甲", "重排合并 乙", "重排合并 丙", "重排合并 丁", "重排合并 戊", "重排合并 己"];
let mut id_to_text: BTreeMap<i64, String> = BTreeMap::new();
for text in texts {
let id = kb.memories().upsert(memory(text, "public")).unwrap().value.header.id;
id_to_text.insert(id, text.to_string());
}
fill(&kb, "v");
let kinds = vec![RecordKind::Memory];
let text_only = SearchRequest { query: "重排合并".into(), kinds: kinds.clone(), vector: false, rerank: false, limit: 6, ..Default::default() };
let text_order: Vec<i64> = kb.search(&text_only).unwrap().hits.iter().map(|hit| hit.key.id).collect();
let vector_only = SearchRequest { query: "重排合并".into(), kinds: kinds.clone(), text: false, rerank: false, limit: 6, embed_space: Some("v".into()), ..Default::default() };
let vector_order: Vec<i64> = kb.search(&vector_only).unwrap().hits.iter().map(|hit| hit.key.id).collect();
let mut seen = std::collections::HashSet::new();
let mut expected: Vec<String> = Vec::new();
let mut index = 0;
while index < text_order.len() || index < vector_order.len() {
if let Some(key) = text_order.get(index) { if seen.insert(*key) { expected.push(id_to_text[key].clone()); } }
if let Some(key) = vector_order.get(index) { if seen.insert(*key) { expected.push(id_to_text[key].clone()); } }
index += 1;
}
let seen_docs: Arc<Mutex<Vec<Vec<String>>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = seen_docs.clone();
kb.register_reranker_with(move |_: &str, documents: &[String]| {
recorder.lock().unwrap().push(documents.to_vec());
Ok(vec![0.0f32; documents.len()])
}, RerankerOptions { max_tokens_total: 12, max_tokens_per_doc: 1024, ..Default::default() }).unwrap();
seen_docs.lock().unwrap().clear();
let result = kb.search(&SearchRequest { query: "重排合并".into(), kinds, limit: 6, embed_space: Some("v".into()), ..Default::default() }).unwrap();
assert!(result.diagnostics.reranked);
assert_eq!(result.diagnostics.rerank_candidates, 3);
let seen = seen_docs.lock().unwrap();
assert_eq!(seen.len(), 1);
let expected_docs: Vec<String> = expected.into_iter().take(3).collect();
assert_eq!(seen[0], expected_docs, "送重排的候选是两路名次交替合并,不是 RRF 截断出来的");
}
#[test]
fn rerank_failure_falls_back_to_the_full_candidate_set() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
for tag in ["甲", "乙", "丙", "丁", "戊"] { kb.memories().upsert(memory(&format!("兜底目标 {tag}"), "public")).unwrap(); }
let request = SearchRequest { query: "兜底目标".into(), kinds: vec![RecordKind::Memory], vector: false, limit: 5, ..Default::default() };
let baseline: Vec<i64> = kb.search(&SearchRequest { rerank: false, ..request.clone() }).unwrap().hits.iter().map(|hit| hit.key.id).collect();
assert_eq!(baseline.len(), 5);
kb.register_reranker_with(|_: &str, _: &[String]| Err("重排服务不可用".to_string()),
RerankerOptions { max_tokens_total: 10, max_tokens_per_doc: 1024, ..Default::default() }).unwrap();
let result = kb.search(&request).unwrap();
assert!(!result.diagnostics.reranked);
assert!(result.diagnostics.degraded.contains(&Degrade::RerankFailed));
assert_eq!(result.diagnostics.rerank_truncated, 3);
assert_eq!(result.hits.iter().map(|hit| hit.key.id).collect::<Vec<_>>(), baseline, "重排挂了也要给出完整的融合排序结果");
}
#[test]
fn rerank_is_not_called_when_there_are_no_candidates() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.memories().upsert(memory("无关内容", "public")).unwrap();
let calls = Arc::new(Mutex::new(0usize));
let recorder = calls.clone();
kb.register_reranker(move |_: &str, documents: &[String]| {
*recorder.lock().unwrap() += 1;
Ok(vec![0.0f32; documents.len()])
}).unwrap();
*calls.lock().unwrap() = 0; let result = kb.search(&SearchRequest { query: "查不到的词".into(), kinds: vec![RecordKind::Memory], vector: false, ..Default::default() }).unwrap();
assert!(result.hits.is_empty());
assert_eq!(*calls.lock().unwrap(), 0, "没有候选就不该调重排回调");
assert!(!result.diagnostics.degraded.contains(&Degrade::RerankFailed));
}
#[test]
fn reranker_registration_validates_and_failures_degrade() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
for i in 0..3 { kb.memories().upsert(memory(&format!("降级目标 {i}"), "public")).unwrap(); }
assert!(kb.register_reranker(|_: &str, _: &[String]| Ok(vec![1.0f32])).is_err());
assert!(kb.register_reranker(|_: &str, documents: &[String]| Ok(vec![f32::NAN; documents.len()])).is_err());
assert!(!kb.reranker_registered());
kb.register_reranker(|_: &str, _: &[String]| Err("重排服务不可用".to_string())).unwrap();
assert!(kb.reranker_registered());
let request = SearchRequest { query: "降级目标".into(), kinds: vec![RecordKind::Memory], vector: false, ..Default::default() };
let result = kb.search(&request).unwrap();
assert!(!result.diagnostics.reranked);
assert!(result.diagnostics.degraded.contains(&Degrade::RerankFailed));
assert_eq!(result.hits.len(), 3, "重排挂了也要给结果,只是按融合分排序");
}
#[test]
fn oversized_batches_shrink_to_fit() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.embeddings().register_space(EmbeddingSpace { id: "v".into(), model: "fixture/v1".into(), dimension: 4, text_version: 1, encoding: "f32".into() }).unwrap();
for i in 0..8 { kb.memories().upsert(memory(&format!("批次 {i}"), "public")).unwrap(); }
let lengths: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = lengths.clone();
kb.embeddings().register_embedder_with("v", move |texts: &[String]| {
recorder.lock().unwrap().push(texts.len());
if texts.len() > 4 { return Err(EmbedCallbackError::too_large("at most 4")); }
Ok(texts.iter().map(|text| fallback_vector(text, 4)).collect())
}, EmbedderOptions { max_batch: 8, max_tokens_per_text: None }).unwrap();
kb.embeddings().sync("v", 32).unwrap();
assert!(wait_until_ready(&kb, "default", "v", "memory", 15_000), "八条都补上了");
let calls = lengths.lock().unwrap().clone();
let shrink_at = calls.iter().position(|len| *len == 8).expect("先按声明的 8 条试一次");
assert_eq!(calls.iter().filter(|len| **len == 8).count(), 1, "减半后不再从 8 重试");
assert!(calls[shrink_at + 1..].iter().all(|len| *len <= 4), "此后一律沿用减半后的 4");
}
#[test]
fn swapping_models_keeps_the_old_space_usable() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
for i in 0..3 { kb.memories().upsert(memory(&format!("换模型 {i}"), "public")).unwrap(); }
space(&kb, "old", 4);
kb.embeddings().sync("old", 32).unwrap();
kb.embeddings().register_space(EmbeddingSpace { id: "new".into(), model: "fixture/v2".into(), dimension: 8, text_version: 1, encoding: "sq8".into() }).unwrap();
kb.embeddings().register_embedder("new", FakeEmbedder::new(8)).unwrap();
kb.embeddings().sync("new", 32).unwrap();
assert!(wait_until_ready(&kb, "default", "new", "memory", 15_000), "换上新模型后补齐并标记就绪");
let kinds = vec![RecordKind::Memory];
let old_hits = kb.search(&vector_query("old", "换模型 1", kinds.clone())).unwrap().hits;
let new_hits = kb.search(&vector_query("new", "换模型 1", kinds.clone())).unwrap().hits;
assert_eq!(old_hits.len(), 3); assert_eq!(new_hits.len(), 3);
assert_eq!(old_hits[0].key.id, new_hits[0].key.id);
let mut request = vector_query("new", "换模型 1", kinds);
request.limit = 10;
let mut ids: Vec<i64> = kb.search(&request).unwrap().hits.iter().map(|hit| hit.key.id).collect();
ids.sort_unstable();
assert_eq!(ids, vec![1, 2, 3]);
assert!(kb.embeddings().register_space(EmbeddingSpace { id: "old".into(), model: "fixture/v9".into(), dimension: 4, text_version: 1, encoding: "f32".into() }).is_err());
}
#[test]
fn vector_partitions_are_isolated_by_scope() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.embeddings().register_space(EmbeddingSpace { id: "v".into(), model: "fixture/v1".into(), dimension: 2, text_version: 1, encoding: "f32".into() }).unwrap();
kb.embeddings().register_embedder("v", FakeEmbedder::with_table(2, &[
("public vector\n", vec![1.0, 0.0]),
("private vector\n", vec![0.0, 1.0]),
("probe", vec![1.0, 0.0]),
])).unwrap();
let public_id = kb.memories().upsert(memory("public vector", "public")).unwrap().value.header.id;
let private_id = kb.memories().upsert(memory("private vector", "private")).unwrap().value.header.id;
fill(&kb, "v");
let query = |scope: &str| SearchRequest {
query: "probe".into(), text: false, embed_space: Some("v".into()),
filter: ReadFilter { scopes: vec![scope.into()], ..Default::default() },
..Default::default()
};
let public = kb.search(&query("public")).unwrap().hits;
assert_eq!(public.len(), 1); assert_eq!(public[0].key.id, public_id);
let private = kb.search(&query("private")).unwrap().hits;
assert!(private.iter().all(|hit| hit.key.id == private_id), "public 分区的向量混进了 private 查询");
assert!(kb.search(&query("empty")).unwrap().hits.is_empty());
}
#[test]
fn packed_and_precise_spaces_rank_the_same_vectors_together() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
for i in 0..8 { kb.memories().upsert(memory(&format!("记录{i}"), "public")).unwrap(); }
for (id, encoding) in [("precise", "f32"), ("packed", "sq8")] {
kb.embeddings().register_space(EmbeddingSpace { id: id.into(), model: "fixture/v1".into(), dimension: 8, text_version: 1, encoding: encoding.into() }).unwrap();
kb.embeddings().register_embedder(id, FakeEmbedder::new(8)).unwrap();
kb.embeddings().sync(id, 50).unwrap();
assert!(wait_until_ready(&kb, "default", id, "memory", 15_000), "空间 {id} 补齐并标记就绪");
}
let kinds = vec![RecordKind::Memory];
let precise = kb.search(&vector_query("precise", "记录3", kinds.clone())).unwrap().hits;
let packed = kb.search(&vector_query("packed", "记录3", kinds)).unwrap().hits;
let precise_ids: Vec<i64> = precise.iter().map(|h| h.key.id).collect();
let packed_ids: Vec<i64> = packed.iter().map(|h| h.key.id).collect();
assert_eq!(precise_ids.len(), 8);
assert_eq!(precise_ids, packed_ids, "sq8 空间的名次应与 f32 空间一致");
for (a, b) in precise.iter().zip(packed.iter()) {
let (x, y) = (a.vector_scores["precise"], b.vector_scores["packed"]);
assert!((x - y).abs() < 5e-3, "{x} vs {y}");
}
}
#[test]
fn concurrent_reads_are_isolated_and_agree_with_serial_results() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
for (i, scope) in (0..8).map(|i| (i, format!("scope{i}"))) {
let mut value = memory(&format!("内容 {i} 独有"), &scope);
value.record.tags = vec![format!("tag{i}")];
kb.memories().upsert(value).unwrap();
}
let expected: Vec<usize> = (0..8).map(|i| {
kb.search(&SearchRequest { query: format!("内容 {i}"), filter: ReadFilter { scopes: vec![format!("scope{i}")], ..Default::default() },
limit: 10, ..Default::default() }).unwrap().hits.len()
}).collect();
assert!(expected.iter().all(|n| *n == 1));
let mut handles = Vec::new();
for round in 0..24 {
let kb = kb.clone();
let expected = expected.clone();
handles.push(std::thread::spawn(move || {
let i = round % 8;
let request = SearchRequest { query: format!("内容 {i}"),
filter: ReadFilter { namespace: default_namespace(), scopes: vec![format!("scope{i}")], tags: vec![format!("tag{i}")], note_ids: vec![] },
limit: 10, ..Default::default() };
for _ in 0..40 {
let hits = kb.search(&request).unwrap().hits;
assert_eq!(hits.len(), expected[i], "并发读结果与串行不一致");
assert_eq!(hits[0].record["scope"], format!("scope{i}"));
}
}));
}
for handle in handles { handle.join().unwrap(); }
kb.memories().upsert(memory("收尾", "scope0")).unwrap();
assert_eq!(kb.health().unwrap().record_count, 9);
}
fn read_rounds(kb: &KnowledgeBase, request: &SearchRequest, threads: usize, rounds: usize) -> std::time::Duration {
let start = std::time::Instant::now();
let mut readers = Vec::new();
for _ in 0..threads {
let kb = kb.clone();
let request = request.clone();
readers.push(std::thread::spawn(move || {
for _ in 0..rounds { assert!(!kb.search(&request).unwrap().hits.is_empty()); }
}));
}
for reader in readers { reader.join().unwrap(); }
start.elapsed()
}
#[test]
fn concurrent_readers_are_not_blocked_by_a_writer() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.memories().upsert(memory("初始内容", "public")).unwrap();
kb.update_index().unwrap();
let request = SearchRequest { query: "初始内容".into(), limit: 5, ..Default::default() };
let quiet = read_rounds(&kb, &request, 4, 60);
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = stop.clone();
let writer_kb = kb.clone();
let writer = std::thread::spawn(move || {
let mut n = 0usize;
while !flag.load(std::sync::atomic::Ordering::Relaxed) {
writer_kb.memories().upsert(memory(&format!("并发写入 {n}"), "public")).unwrap();
if n % 16 == 15 { writer_kb.update_index().unwrap(); }
n += 1;
}
n
});
let busy = read_rounds(&kb, &request, 4, 60);
stop.store(true, std::sync::atomic::Ordering::Relaxed);
let written = writer.join().unwrap();
assert!(written > 0, "写者在读者跑完前一条都没写成,这次比较没有意义");
assert!(busy.as_secs_f64() < quiet.as_secs_f64() * 4.0,
"写者进行中读者不应排队等锁:无写入 {quiet:?},有写入 {busy:?}(写者写入 {written} 条)");
}
#[test]
fn graph_integrity_aliases_and_rename_propagation() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap(); space(&kb, "v", 4);
let mut alice = entity("Alice"); alice.aliases = vec!["小艾".into()];
let mut bob = entity("Bob"); bob.aliases = vec!["小艾".into()];
let entities = kb.graph().apply_batch(&GraphBatch { entities: vec![alice, bob], ..Default::default() }).unwrap().value.entities;
let a = entities[0].header.id; let b = entities[1].header.id;
let relation = RelationInput { record: RecordInput::default(), subject_id: a, predicate: "knows".into(), object_id: b, confidence: 0.8, reason: String::new() };
let event = EventInput { record: RecordInput::default(), name: "meeting".into(), summary: String::new(), participants: vec![a, b], confidence: 0.9, reason: String::new() };
kb.graph().apply_batch(&GraphBatch { entities: vec![], relations: vec![relation.clone()], events: vec![event] }).unwrap();
assert_eq!(kb.graph().resolve("小艾", &ReadFilter::default(), 10).unwrap().len(), 2);
assert_eq!(kb.graph().neighbors(a, &ReadFilter::default(), 10).unwrap().entities[0].name, "Bob");
assert_eq!(kb.graph().events_for_entity(a, &ReadFilter::default(), 10).unwrap().len(), 1);
assert!(matches!(kb.graph().delete(RecordKind::Entity, a, &ReadFilter::default()), Err(Error::Conflict(_))));
let mut renamed = entity("Carol"); renamed.record.id = Some(a);
kb.graph().apply_batch(&GraphBatch { entities: vec![renamed], ..Default::default() }).unwrap();
fill(&kb, "v");
let mut request = vector_query("v", "Carol", vec![RecordKind::Relation, RecordKind::Event]);
request.limit = 10;
let hits = kb.search(&request).unwrap().hits;
assert_eq!(hits.len(), 2, "关系与事件在改名后应当带着新向量回到向量路");
let search = SearchRequest {query:"Carol".into(),kinds:vec![RecordKind::Relation,RecordKind::Event],..Default::default()};
assert_eq!(kb.search(&search).unwrap().hits.len(), 2);
let bad = RelationInput { object_id: 9_999_999, ..relation };
assert!(kb.graph().apply_batch(&GraphBatch { entities: vec![], relations: vec![bad], events: vec![] }).is_err());
}
#[test]
fn note_replacement_keeps_evidence_and_removes_stale_chunks() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
let tea_path = dir.path().join("tea.md");
std::fs::write(&tea_path, "# 茶\n\n上海喝茶\n\n```rust\nlet x = 1;\n```\n\n最后一段").unwrap();
let note = kb.notes().upsert_file(NoteFileInput::new(&tea_path)).unwrap().value;
let note_id = note.header.id;
let chunks = kb.notes().chunks(note_id, &ReadFilter::default()).unwrap();
let tea = chunks.iter().find(|c| c.content == "上海喝茶").unwrap();
assert_eq!((tea.offset, tea.limit), (3, 1));
assert!(chunks.iter().any(|c| c.content.contains("```rust") && c.offset == 5 && c.limit == 3));
let evidence = Evidence { source: "docs/tea.md".into(), offset: Some(3), limit: Some(1), quote: "上海喝茶".into(), ..Default::default() };
let mut m = memory("source fact", "public"); m.record.evidence = vec![evidence];
let memory_id = kb.memories().upsert(m).unwrap().value.header.id;
std::fs::write(&tea_path, "replacement").unwrap();
let new = kb.notes().upsert_file(NoteFileInput::new(&tea_path)).unwrap().value;
assert_eq!(new.header.id, note_id);
assert!(kb.notes().get_chunk(tea.header.id, &ReadFilter::default()).is_err());
assert_eq!(kb.memories().get(memory_id, &ReadFilter::default()).unwrap().header.evidence[0].quote, "上海喝茶");
kb.notes().delete(note_id, &ReadFilter::default()).unwrap();
assert_eq!(kb.health().unwrap().record_count, 1);
assert_eq!(kb.health().unwrap().foreign_key_errors, 0);
let long = chunk_text(&"一".repeat(500), 220).unwrap();
assert_eq!(long.len(), 3); assert!(long.iter().all(|c| c.offset == 1 && c.limit == 1));
}
#[test]
fn explicit_lifecycle_has_no_hidden_deletion() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
let mut weak = memory("weak", "public"); weak.record.created_at_us = Some(0); weak.record.updated_at_us = Some(0);
let weak_id = kb.memories().upsert(weak).unwrap().value.header.id;
let mut pinned = memory("pinned", "public"); pinned.state = Some(MemoryState { pinned: true, ..Default::default() });
kb.memories().upsert(pinned).unwrap();
let result = kb.memories().decay(&ReadFilter::default(), &DecayPolicy::default(), Some(4*86_400_000_000)).unwrap().value;
assert_eq!(result.decayed,1); assert_eq!(result.retirement_candidates[0].id, weak_id); assert_eq!(kb.health().unwrap().record_count,2);
let feedback = FeedbackRequest {recalled_ids:vec![weak_id],useful_ids:vec![weak_id],now_us:Some(5*86_400_000_000),..Default::default()};
kb.memories().feedback(&feedback).unwrap(); assert_eq!(kb.memories().get(weak_id, &ReadFilter::default()).unwrap().state.strength,1);
let bad = FeedbackRequest {useful_ids:vec![9_999_999],..Default::default()}; assert!(kb.memories().feedback(&bad).is_err());
let second = kb.memories().decay(&ReadFilter::default(), &DecayPolicy::default(), Some(5*86_400_000_000)).unwrap(); assert_eq!(second.value.decayed,0);
}
#[test]
fn backup_restore_and_derived_index_recovery() {
let root=tempfile::tempdir().unwrap();let data=root.path().join("data");let kb=KnowledgeBase::open(&data).unwrap();
kb.memories().upsert(memory("recoverable","public")).unwrap();space(&kb,"v",2);
let vector_id = kb.memories().upsert(memory("带向量的记录","public")).unwrap().value.header.id;
fill(&kb,"v");
assert_eq!(kb.search(&vector_query("v", "带向量的记录", vec![RecordKind::Memory])).unwrap().hits[0].key.id, vector_id);
let backup=root.path().join("backup.sqlite3");kb.backup(&backup).unwrap();assert!(kb.backup(&backup).is_err());kb.close().unwrap();
std::fs::write(data.join("text-v2/meta.json"),b"broken index metadata").unwrap();
let reopened=KnowledgeBase::open(&data).unwrap();assert_eq!(reopened.search(&SearchRequest {query:"recoverable".into(),..Default::default()}).unwrap().hits.len(),1);
let restored=KnowledgeBase::restore(&backup,root.path().join("restored")).unwrap();
assert_eq!(restored.health().unwrap().record_count,2);
space(&restored,"v",2);
assert_eq!(restored.search(&vector_query("v", "带向量的记录", vec![RecordKind::Memory])).unwrap().hits[0].key.id, vector_id);
assert!(KnowledgeBase::restore(&backup,&data).is_err());
assert_eq!(json!(restored.health().unwrap())["sqlite_integrity"],"ok");
}
#[test]
fn ties_and_chinese_queries_are_deterministic_across_rebuilds() {
let dir=tempfile::tempdir().unwrap();let kb=KnowledgeBase::open(dir.path()).unwrap();
let values:Vec<_>=(0..140).map(|_| memory("上海 茶 相同", "public")).collect();kb.memories().upsert_many(&values).unwrap();
let q=SearchRequest {query:"上海 茶".into(),limit:3,candidate_limit:Some(3),..Default::default()};
let ids=|kb:&KnowledgeBase|kb.search(&q).unwrap().hits.into_iter().map(|h|h.key.id).collect::<Vec<_>>();
let first=ids(&kb);assert_eq!(first.len(),3);assert!(first.windows(2).all(|w|w[0]<w[1]));
kb.rebuild_indexes().unwrap();assert_eq!(ids(&kb),first);
}
#[test]
fn the_tag_set_rides_on_the_owning_record_and_carries_queries_the_body_cannot() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let mut m = memory("她喜欢苹果", "public");
m.record.tags = vec!["星见雅".into()];
let id = kb.memories().upsert(m).unwrap().value.header.id;
let hits = kb.search(&SearchRequest { query: "星见雅".into(), ..Default::default() }).unwrap().hits;
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].key.id, id);
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
let note_dir = dir.path().join("绝区零").join("角色");
std::fs::create_dir_all(¬e_dir).unwrap();
let note_path = note_dir.join("雅.md");
std::fs::write(¬e_path, "这是一段无关内容").unwrap();
let note = kb.notes().upsert_file(NoteFileInput::new(¬e_path)).unwrap().value;
let chunks = kb.notes().chunks(note.header.id, &ReadFilter::default()).unwrap();
assert_eq!(chunks.len(), 1);
for word in ["绝区零", "角色"] {
let hits = kb.search(&SearchRequest { query: word.into(), kinds: vec![RecordKind::Chunk], ..Default::default() }).unwrap().hits;
assert!(hits.is_empty(), "目录段不参与常规匹配:{word}");
}
let hits = kb.search(&SearchRequest { query: "雅".into(), kinds: vec![RecordKind::Chunk], ..Default::default() }).unwrap().hits;
assert_eq!(hits.len(), 1, "文件名是名字,能搜到");
assert_eq!(hits[0].key.id, chunks[0].header.id);
assert_eq!(chunks[0].content, "这是一段无关内容", "取回的是纯正文");
let hits = kb.search(&SearchRequest { query: "雅".into(), kinds: vec![RecordKind::Note], ..Default::default() }).unwrap().hits;
assert!(hits.is_empty(), "笔记不进索引");
let page = kb.notes().list(&PageRequest { filter: ReadFilter { tags: vec!["角色".into()], ..Default::default() }, ..Default::default() }).unwrap();
assert_eq!(page.items.len(), 1, "按目录段筛笔记仍能筛出这一篇");
}
#[test]
fn sq8_encoding_roundtrips_through_storage() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.embeddings().register_space(EmbeddingSpace { id: "q".into(), model: "fixture/v1".into(), dimension: 4, text_version: 1, encoding: "sq8".into() }).unwrap();
assert!(kb.embeddings().register_space(EmbeddingSpace { id: "bad".into(), model: "fixture/v1".into(), dimension: 4, text_version: 1, encoding: "q4".into() }).is_err());
kb.embeddings().register_embedder("q", FakeEmbedder::with_table(4, &[
("quantized target\n", vec![3.0, 4.0, 0.0, 0.0]),
("probe", vec![0.6, 0.8, 0.0, 0.0]),
])).unwrap();
let id = kb.memories().upsert(memory("quantized target", "public")).unwrap().value.header.id;
fill(&kb, "q");
let hits = kb.search(&vector_query("q", "probe", vec![RecordKind::Memory])).unwrap().hits;
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].key.id, id);
assert!((hits[0].score - 1.0 / 61.0).abs() < 1e-9);
}
#[test]
fn graph_prune_limits_vector_scoring_to_neighborhood() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
space(&kb, "v", 2);
let ents = kb
.graph()
.apply_batch(&GraphBatch { entities: vec![entity("A"), entity("B"), entity("C")], ..Default::default() })
.unwrap()
.value
.entities;
let (a, b) = (ents[0].header.id, ents[1].header.id);
kb.graph()
.apply_batch(&GraphBatch { relations: vec![RelationInput { record: RecordInput::default(), subject_id: a, predicate: "knows".into(), object_id: b, confidence: 1.0, reason: String::new() }], ..Default::default() })
.unwrap();
fill(&kb, "v");
let base = vector_query("v", "probe", vec![RecordKind::Entity]);
let mut wide = base.clone(); wide.limit = 10;
assert_eq!(kb.search(&wide).unwrap().hits.len(), 3);
let pruned = SearchRequest { prune: Some(GraphPrune { root: a, depth: 1, limit: 64 }), limit: 10, ..base.clone() };
let hits = kb.search(&pruned).unwrap().hits;
let ids: Vec<i64> = hits.iter().map(|h| h.key.id).collect();
assert_eq!(hits.len(), 2);
assert!(ids.contains(&a) && ids.contains(&b));
assert!(kb.search(&SearchRequest { prune: Some(GraphPrune { root: a, depth: 0, limit: 8 }), ..base.clone() }).is_err());
}
#[test]
fn search_with_context_attaches_entity_neighborhood() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let mut suspect = entity("张三");
suspect.aliases = vec!["嫌疑人".into()];
let ents = kb
.graph()
.apply_batch(&GraphBatch { entities: vec![suspect, entity("李四")], ..Default::default() })
.unwrap()
.value
.entities;
let (zhang, li) = (ents[0].header.id, ents[1].header.id);
kb.graph()
.apply_batch(&GraphBatch { relations: vec![RelationInput { record: RecordInput::default(), subject_id: zhang, predicate: "knows".into(), object_id: li, confidence: 1.0, reason: String::new() }], ..Default::default() })
.unwrap();
let mut note = memory("深夜的会面记录", "public");
note.record.tags = vec!["嫌疑人".into()];
let linked_id = kb.memories().upsert(note).unwrap().value.header.id;
let plain_id = kb.memories().upsert(memory("深夜的另一次会面", "public")).unwrap().value.header.id;
let request = SearchRequest { query: "深夜 会面".into(), kinds: vec![RecordKind::Memory], ..Default::default() };
let hits = kb.search_with_context(&request, 10).unwrap();
assert_eq!(hits.len(), 2);
let linked = hits.iter().find(|h| h.hit.key.id == linked_id).unwrap();
let names: Vec<&str> = linked.context.entities.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"张三") && names.contains(&"李四"));
assert_eq!(linked.context.relations.len(), 1);
let plain = hits.iter().find(|h| h.hit.key.id == plain_id).unwrap();
assert!(plain.context.entities.is_empty() && plain.context.relations.is_empty());
}
#[test]
fn batched_context_matches_per_hit_expansion() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let mut a = entity("甲真人");
a.aliases = vec!["甲".into()];
let mut b = entity("乙真人");
b.aliases = vec!["乙".into()];
let c = entity("丙真人");
let mut shadow = entity("甲分身");
shadow.aliases = vec!["甲".into()];
shadow.record.scope = "private".into();
let ents = kb.graph().apply_batch(&GraphBatch { entities: vec![a, b, c, shadow], ..Default::default() }).unwrap().value.entities;
let (a_id, b_id, c_id, shadow_id) = (ents[0].header.id, ents[1].header.id, ents[2].header.id, ents[3].header.id);
let relation = |subject_id: i64, object_id: i64, predicate: &str| RelationInput {
record: RecordInput::default(), subject_id, object_id, predicate: predicate.into(), confidence: 1.0, reason: String::new(),
};
kb.graph().apply_batch(&GraphBatch { relations: vec![relation(a_id, b_id, "认识"), relation(a_id, c_id, "同伙")], ..Default::default() }).unwrap();
let mut first = memory("共同出现的甲", "public"); first.record.tags = vec!["甲".into()];
let first_id = kb.memories().upsert(first).unwrap().value.header.id;
let mut second = memory("共同出现的乙", "public"); second.record.tags = vec!["乙".into()];
let second_id = kb.memories().upsert(second).unwrap().value.header.id;
let request = SearchRequest { query: "共同出现".into(), kinds: vec![RecordKind::Memory], ..Default::default() };
let hits = kb.search_with_context(&request, 1).unwrap();
assert_eq!(hits.len(), 2);
let first_ctx = &hits.iter().find(|h| h.hit.key.id == first_id).unwrap().context;
assert_eq!(first_ctx.relations.len(), 1);
let names: Vec<&str> = first_ctx.entities.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"甲真人") && (names.contains(&"乙真人") || names.contains(&"丙真人")), "邻域应带回种子实体与端点实体");
assert!(!names.contains(&"甲分身"), "跨 scope 的同名实体不该被挂上");
let wide = kb.search_with_context(&request, 10).unwrap();
let wide_first = &wide.iter().find(|h| h.hit.key.id == first_id).unwrap().context;
assert_eq!(wide_first.relations.len(), 2);
let names: Vec<&str> = wide_first.entities.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"甲真人") && names.contains(&"乙真人") && names.contains(&"丙真人"));
assert!(!names.contains(&"甲分身"));
let second_ctx = &wide.iter().find(|h| h.hit.key.id == second_id).unwrap().context;
assert_eq!(second_ctx.relations.len(), 1);
assert_eq!(second_ctx.relations[0].predicate, "认识");
assert!(kb.graph().get(RecordKind::Entity, shadow_id, &ReadFilter { scopes: vec!["private".into()], ..Default::default() }).is_ok());
}
#[test]
fn text_gate_excludes_records_that_do_not_match_the_query() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
for i in 0..20 { kb.memories().upsert(memory(&format!("记忆 {i} 独有措辞"), "public")).unwrap(); }
let missing = kb.search(&SearchRequest { query: "量子纠缠zzz".into(), limit: 10, ..Default::default() }).unwrap().hits;
assert!(missing.is_empty(), "正文条件失效:过滤域内的 {} 条记录被当成了命中", missing.len());
let target = kb.memories().upsert(memory("全息投影仪维修记录", "public")).unwrap().value.header.id;
let hits = kb.search(&SearchRequest { query: "全息投影仪".into(), limit: 10, ..Default::default() }).unwrap().hits;
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].key.id, target);
}
#[test]
fn note_upsert_file_reads_path_uses_stem_and_keeps_raw_text() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
let path = dir.path().join("世界观.md");
let raw = "# 标题\n\n这里有 **独有措辞** 正文。";
std::fs::write(&path, raw).unwrap();
let note = kb.notes().upsert_file(NoteFileInput::new(&path)).unwrap().value;
assert_eq!(note.title, "世界观", "标题取文件名");
assert_eq!(note.source, *path.to_string_lossy(), "路径即身份");
let chunks = kb.notes().chunks(note.header.id, &ReadFilter::default()).unwrap();
assert!(chunks.iter().any(|c| c.content.contains("独有措辞")), "切片正文由文件原文派生");
let hits = kb.search(&SearchRequest { query: "独有措辞".into(), kinds: vec![RecordKind::Chunk], ..Default::default() }).unwrap().hits;
assert!(!hits.is_empty(), "清洗后的文本仍可检索");
std::fs::write(&path, "改过的正文 **新词** 在这里。").unwrap();
let updated = kb.notes().upsert_file(NoteFileInput::new(&path)).unwrap().value;
assert_eq!(updated.header.id, note.header.id, "同一路径复用同一笔记");
let hits = kb.search(&SearchRequest { query: "新词".into(), kinds: vec![RecordKind::Chunk], ..Default::default() }).unwrap().hits;
assert!(!hits.is_empty(), "更新后的正文进入索引");
assert!(kb.notes().upsert_file(NoteFileInput::new(dir.path().join("nope.md"))).is_err());
let bad = dir.path().join("bad.md");
std::fs::write(&bad, [0xffu8, 0xfe, 0xfd]).unwrap();
assert!(kb.notes().upsert_file(NoteFileInput::new(&bad)).is_err());
}
#[test]
fn note_body_lives_only_in_the_index_not_in_sqlite() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
let path = dir.path().join("note.md");
std::fs::write(&path, "ZZBODYMARK 独有正文标记").unwrap();
let note = kb.notes().upsert_file(NoteFileInput::new(&path)).unwrap().value;
let chunks = kb.notes().chunks(note.header.id, &ReadFilter::default()).unwrap();
assert!(chunks.iter().any(|chunk| chunk.content.contains("ZZBODYMARK")));
drop(kb);
for name in ["store.sqlite3", "store.sqlite3-wal"] {
if let Ok(bytes) = std::fs::read(dir.path().join(name)) {
assert!(!String::from_utf8_lossy(&bytes).contains("ZZBODYMARK"), "{name} 里出现正文标记");
}
}
let conn = rusqlite::Connection::open(dir.path().join("store.sqlite3")).unwrap();
let mut stmt = conn.prepare("SELECT payload_json||metadata_json||evidence_json FROM records").unwrap();
let hits = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap()
.filter(|row| row.as_ref().unwrap().contains("ZZBODYMARK")).count();
assert_eq!(hits, 0, "笔记正文不落 SQLite");
}
#[test]
fn chunks_carry_their_own_text_and_only_the_first_one_carries_the_path_tags() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let root = dir.path().to_string_lossy().into_owned();
let note_dir = dir.path().join("绝区零").join("角色");
std::fs::create_dir_all(¬e_dir).unwrap();
let bare_path = note_dir.join("无根.md");
std::fs::write(&bare_path, "苹果 香蕉 橘子").unwrap();
assert!(matches!(kb.notes().upsert_file(NoteFileInput::new(&bare_path)), Err(Error::Validation(_))),
"没登记根目录就拒绝写入");
assert_eq!(kb.health().unwrap().record_count, 0, "被拒的笔记一条都不落库");
kb.notes().set_root("default", &root).unwrap();
let short_path = note_dir.join("雅.md");
std::fs::write(&short_path, "苹果 香蕉 橘子").unwrap();
let note = kb.notes().upsert_file(NoteFileInput::new(&short_path)).unwrap().value;
let conn = rusqlite::Connection::open(dir.path().join("store.sqlite3")).unwrap();
let stored: String = conn.query_row("SELECT path FROM notes WHERE record_id=?1", [note.header.id], |r| r.get(0)).unwrap();
assert_eq!(stored, "绝区零/角色/雅.md", "库里存的是减掉根目录的相对路径");
drop(conn);
let chunks = kb.notes().chunks(note.header.id, &ReadFilter::default()).unwrap();
for word in ["绝区零", "角色"] {
let hits = kb.search(&SearchRequest { query: word.into(), kinds: vec![RecordKind::Chunk], ..Default::default() }).unwrap().hits;
assert!(hits.is_empty(), "目录段不参与常规匹配:{word}");
}
let hits = kb.search(&SearchRequest { query: "雅".into(), kinds: vec![RecordKind::Chunk], ..Default::default() }).unwrap().hits;
assert_eq!(hits.len(), 1, "文件名是名字,能搜到");
assert_eq!(hits[0].key.id, chunks[0].header.id);
let long_path = note_dir.join("长文.md");
std::fs::write(&long_path, "青提".repeat(400)).unwrap();
let long_note = kb.notes().upsert_file(NoteFileInput::new(&long_path)).unwrap().value;
let long_chunks = kb.notes().chunks(long_note.header.id, &ReadFilter::default()).unwrap();
assert!(long_chunks.len() > 1, "长正文应当切成多片");
for chunk in &long_chunks { assert!(!chunk.content.contains("长文"), "切片正文不携带文件名"); }
let hits = kb.search(&SearchRequest { query: "长文".into(), kinds: vec![RecordKind::Chunk], limit: 50, ..Default::default() }).unwrap().hits;
assert_eq!(hits.len(), 1, "文件名只挂第一片");
assert_eq!(hits[0].key.id, long_chunks[0].header.id);
let hits = kb.search(&SearchRequest { query: "角色".into(), kinds: vec![RecordKind::Chunk], limit: 50, ..Default::default() }).unwrap().hits;
assert!(hits.is_empty(), "目录段不参与常规匹配,两篇都搜不到");
}
#[test]
fn indexed_text_survives_the_source_file_but_a_rebuild_needs_it() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
let path = dir.path().join("gone.md");
std::fs::write(&path, "会被删掉的正文").unwrap();
let note = kb.notes().upsert_file(NoteFileInput::new(&path)).unwrap().value;
kb.update_index().unwrap();
let chunk_id = kb.notes().chunks(note.header.id, &ReadFilter::default()).unwrap()[0].header.id;
std::fs::remove_file(&path).unwrap();
assert_eq!(kb.notes().get_chunk(chunk_id, &ReadFilter::default()).unwrap().content, "会被删掉的正文",
"正文随写入一起进了索引,源文件没了也读得到");
assert!(kb.rebuild_indexes().is_ok(), "重建遇到缺失文件不应整体失败");
assert_eq!(kb.notes().get_chunk(chunk_id, &ReadFilter::default()).unwrap().content, "",
"重建要回源,源不在的那批切片正文只能为空");
}
#[test]
fn rerank_reads_chunk_bodies_from_the_index_not_from_the_source_file() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
let path = dir.path().join("唯一笔记.md");
std::fs::write(&path, "青提苹果 ZZTEXTMARK").unwrap();
kb.notes().upsert_file(NoteFileInput::new(&path)).unwrap();
kb.update_index().unwrap();
std::fs::remove_file(&path).unwrap();
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = seen.clone();
kb.register_reranker_with(move |_: &str, documents: &[String]| {
recorder.lock().unwrap().extend(documents.iter().cloned());
Ok(vec![0.5; documents.len()])
}, RerankerOptions::default()).unwrap();
seen.lock().unwrap().clear();
let result = kb.search(&SearchRequest { query: "青提苹果".into(), kinds: vec![RecordKind::Chunk],
vector: false, ..Default::default() }).unwrap();
assert!(result.diagnostics.reranked && !result.hits.is_empty());
assert!(seen.lock().unwrap().iter().any(|body| body.contains("ZZTEXTMARK")), "重排候选正文来自索引");
}
#[test]
fn memory_and_chunk_score_the_same_text_identically() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
let text = "青提苹果";
kb.memories().upsert(memory(text, "public")).unwrap();
let path = dir.path().join("同文本.md");
std::fs::write(&path, text).unwrap();
kb.notes().upsert_file(NoteFileInput::new(&path)).unwrap();
let score = |kinds: Vec<RecordKind>| {
let request = SearchRequest { query: text.into(), kinds, vector: false, rerank: false, ..Default::default() };
let result = kb.search(&request).unwrap();
assert_eq!(result.hits.len(), 1, "该类型下只有一条记录");
result.hits[0].text_score.unwrap()
};
assert_eq!(score(vec![RecordKind::Memory]), score(vec![RecordKind::Chunk]), "同文本的文本列分数一致");
}
#[test]
fn domain_root_relative_paths_and_the_tag_set_ride_on_the_first_chunk() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let root = dir.path().join("data").join("domain").join("demo");
std::fs::create_dir_all(root.join("notes").join("characters")).unwrap();
let note_path = root.join("notes").join("characters").join("overview.md");
std::fs::write(¬e_path, "overview ".repeat(300)).unwrap();
kb.notes().set_root("default", &root.to_string_lossy()).unwrap();
assert_eq!(kb.notes().root("default").unwrap(), Some(root.to_string_lossy().replace('\\', "/")));
let outside = dir.path().join("outside.md");
std::fs::write(&outside, "根目录之外的正文").unwrap();
assert!(matches!(kb.notes().upsert_file(NoteFileInput::new(&outside)), Err(Error::Validation(_))));
assert_eq!(kb.health().unwrap().record_count, 0, "越界路径一条记录都不许落库");
let mut input = NoteFileInput::new(¬e_path);
input.record.tags = vec!["人物".into()];
let note = kb.notes().upsert_file(input).unwrap().value;
let chunks = kb.notes().chunks(note.header.id, &ReadFilter::default()).unwrap();
assert!(chunks.len() > 1, "长正文应当切成多片");
for chunk in &chunks {
assert!(!chunk.content.contains("notes") && !chunk.content.contains("characters"), "切片正文不携带路径词");
let mut tags = chunk.header.tags.clone();
tags.sort();
assert_eq!(tags, vec!["characters".to_string(), "notes".to_string(), "overview".to_string(), "人物".to_string()],
"调用方标签与路径段标签合并去重后挂在每一片上");
}
let hits = kb.search(&SearchRequest { query: "overview".into(), kinds: vec![RecordKind::Chunk], limit: 50, ..Default::default() }).unwrap().hits;
assert_eq!(hits.len(), 1, "同一篇笔记折叠成一条");
assert_eq!(hits[0].note_chunks, Some(chunks.len()), "并告诉调用方这一篇共有多少片段命中");
assert!(hits[0].text_score.is_some_and(|score| score > 0.0), "命中的那一片拿到分");
let caller_tag_hits = kb.search(&SearchRequest { query: "人物".into(), kinds: vec![RecordKind::Chunk], limit: 50, ..Default::default() }).unwrap().hits;
assert_eq!(caller_tag_hits.len(), 1, "调用方标签只拼在第一片上");
assert_eq!(caller_tag_hits[0].key.id, chunks[0].header.id, "命中的是第一片");
for word in ["notes", "characters"] {
let hits = kb.search(&SearchRequest { query: word.into(), kinds: vec![RecordKind::Chunk], limit: 50, ..Default::default() }).unwrap().hits;
assert!(hits.is_empty(), "目录段不参与常规匹配:{word}");
}
let by_tag = |tag: &str| kb.search(&SearchRequest { query: "overview".into(),
filter: ReadFilter { tags: vec![tag.into()], ..Default::default() }, kinds: vec![RecordKind::Chunk], limit: 50, ..Default::default() })
.unwrap().hits.len();
assert_eq!(by_tag("characters"), 1, "同一篇折叠成一条,但按目录段筛选仍能定位到它");
assert_eq!(by_tag("库里没有的标签"), 0);
let before = kb.health().unwrap();
assert_eq!(before.index_document_count, before.record_count - 1);
kb.rebuild_indexes().unwrap();
let again = kb.search(&SearchRequest { query: "overview".into(), kinds: vec![RecordKind::Chunk], limit: 50, ..Default::default() }).unwrap().hits;
assert_eq!(again.len(), hits.len(), "重建后命中条数一致");
assert_eq!(again[0].key.id, hits[0].key.id, "重建后名次一致");
assert_eq!(kb.health().unwrap().index_document_count, before.record_count - 1);
}
#[test]
fn chunks_from_one_note_collapse_to_one_hit_with_the_note_total() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
let many_path = dir.path().join("对话.md");
std::fs::write(&many_path, "派蒙".repeat(800)).unwrap();
let many = kb.notes().upsert_file(NoteFileInput::new(&many_path)).unwrap().value;
let many_chunks = kb.notes().chunks(many.header.id, &ReadFilter::default()).unwrap();
assert!(many_chunks.len() > 1, "这篇应当切成多片");
let once_path = dir.path().join("独白.md");
std::fs::write(&once_path, "派蒙").unwrap();
let once = kb.notes().upsert_file(NoteFileInput::new(&once_path)).unwrap().value;
let hits = kb.search(&SearchRequest { query: "派蒙".into(), kinds: vec![RecordKind::Chunk], limit: 50, ..Default::default() }).unwrap().hits;
assert_eq!(hits.len(), 2, "两篇各出且只出一条,多片段那篇不许刷屏");
let many_hit = hits.iter().find(|hit| hit.record["note_id"] == json!(many.header.id)).unwrap();
assert_eq!(many_hit.note_chunks, Some(many_chunks.len()), "多片段那篇报出它的片段总数");
let once_hit = hits.iter().find(|hit| hit.record["note_id"] == json!(once.header.id)).unwrap();
assert_eq!(once_hit.note_chunks, Some(1), "单片段那篇报 1");
assert_eq!(many_hit.top_chunks.len(), 3, "默认把这一篇排名最高的三片聚合在一条里");
assert_eq!(many_hit.top_chunks[0].id, many_hit.key.id, "第 0 条就是本条自身");
assert_eq!(many_hit.top_chunks[0].offset, many_hit.record["offset"].as_u64().unwrap() as usize);
for chunk in &many_hit.top_chunks {
let source = many_chunks.iter().find(|candidate| candidate.header.id == chunk.id)
.expect("聚合进来的都是这一篇的片段");
assert_eq!(chunk.offset, source.offset, "行号取自 chunks 表,与正文定位一致");
}
assert_eq!(once_hit.top_chunks.len(), 1, "只命中一片的篇,聚合里就它自己");
assert_eq!(once_hit.top_chunks[0].id, once_hit.key.id);
let narrow = kb.search(&SearchRequest { query: "派蒙".into(), kinds: vec![RecordKind::Chunk], limit: 1, ..Default::default() }).unwrap().hits;
assert_eq!(narrow.len(), 1);
assert_eq!(narrow[0].note_chunks, Some(many_chunks.len()), "窗口变小,片段计数不变");
let plain = kb.search(&SearchRequest { query: "派蒙".into(), kinds: vec![RecordKind::Chunk], limit: 50,
top_chunks_per_note: 0, ..Default::default() }).unwrap().hits;
let plain_many = plain.iter().find(|hit| hit.record["note_id"] == json!(many.header.id)).unwrap();
assert!(plain_many.top_chunks.is_empty(), "关掉聚合就不带片段");
assert_eq!(plain_many.note_chunks, Some(many_chunks.len()), "计数与聚合开关无关");
let single = kb.search(&SearchRequest { query: "派蒙".into(), kinds: vec![RecordKind::Chunk],
limit: 1, candidate_limit: Some(1), ..Default::default() }).unwrap().hits;
assert_eq!(single.len(), 1);
assert_eq!(single[0].top_chunks.len(), 1, "窗口里只有这一片,聚合就一片");
assert_eq!(single[0].top_chunks[0].id, single[0].key.id);
let by_name = kb.search(&SearchRequest { query: "对话".into(), kinds: vec![RecordKind::Chunk],
match_field: MatchField::Name, ..Default::default() }).unwrap().hits;
assert!(!by_name.is_empty());
assert!(by_name.iter().all(|hit| hit.note_chunks.is_none()), "只看名字列时不报片段数");
assert!(by_name.iter().all(|hit| hit.top_chunks.is_empty()), "只看名字列时不聚合片段");
}
#[test]
fn rerank_receives_one_candidate_per_note() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
let path = dir.path().join("对话.md");
std::fs::write(&path, "派蒙".repeat(800)).unwrap();
let note = kb.notes().upsert_file(NoteFileInput::new(&path)).unwrap().value;
assert!(kb.notes().chunks(note.header.id, &ReadFilter::default()).unwrap().len() > 1, "这篇应当切成多片");
let seen: Arc<Mutex<Vec<Vec<String>>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = seen.clone();
kb.register_reranker_with(move |_: &str, documents: &[String]| {
recorder.lock().unwrap().push(documents.to_vec());
Ok(vec![0.0f32; documents.len()])
}, RerankerOptions::default()).unwrap();
seen.lock().unwrap().clear();
let result = kb.search(&SearchRequest { query: "派蒙".into(), kinds: vec![RecordKind::Chunk],
limit: 10, ..Default::default() }).unwrap();
assert!(result.diagnostics.reranked);
assert_eq!(result.diagnostics.rerank_candidates, 1, "同一篇只送一条进重排");
assert_eq!(seen.lock().unwrap()[0].len(), 1);
}
fn relation_of(subject_id: i64, predicate: &str, object_id: i64) -> RelationInput {
RelationInput { record: RecordInput::default(), subject_id, predicate: predicate.into(), object_id, confidence: 1.0, reason: String::new() }
}
fn event_of(name: &str, participants: Vec<i64>) -> EventInput {
EventInput { record: RecordInput::default(), name: name.into(), summary: String::new(), participants, confidence: 1.0, reason: String::new() }
}
#[test]
fn preset_graph_walks_entities_then_relations_then_events() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let mut hua = entity("朱樱");
hua.aliases = vec!["alias-one".into()];
let created = kb.graph().apply_batch(&GraphBatch {
entities: vec![hua, entity("白露"), entity("青萍"), entity("玄霜"), entity("长夜堂")], ..Default::default()
}).unwrap().value;
let id = |name: &str| created.entities.iter().find(|entity| entity.name == name).unwrap().header.id;
kb.graph().apply_batch(&GraphBatch {
relations: vec![
relation_of(id("朱樱"), "同学", id("青萍")),
relation_of(id("白露"), "同学", id("玄霜")),
relation_of(id("青萍"), "同门", id("玄霜")),
relation_of(id("朱樱"), "客卿于", id("长夜堂")),
],
events: vec![
event_of("别鹤典仪", vec![id("朱樱"), id("青萍")]),
event_of("堂中自语", vec![id("青萍")]),
event_of("开张", vec![id("长夜堂")]),
],
..Default::default()
}).unwrap();
kb.memories().upsert(memory("朱樱的同学是青萍", "public")).unwrap();
let result = kb.search_preset(&PresetRequest {
preset: SearchPreset::Rag, query: "朱樱和白露的同学是谁".into(), ..Default::default()
}).unwrap();
let mut names: Vec<&str> = result.graph.entities.iter().map(|entity| entity.name.as_str()).collect();
names.sort();
assert_eq!(names, vec!["朱樱", "白露"], "查询词命中的实体成为种子");
let hua = result.graph.entities.iter().find(|entity| entity.name == "朱樱").unwrap();
assert_eq!(hua.aliases, vec!["alias-one".to_string()], "实体连别名一起给出");
let hits: Vec<(i64, String, i64)> = result.graph.relations.iter()
.map(|relation| (relation.subject_id, relation.predicate.clone(), relation.object_id)).collect();
assert!(hits.contains(&(id("朱樱"), "同学".into(), id("青萍"))), "朱樱那边的同学关系是结果");
assert!(hits.contains(&(id("白露"), "同学".into(), id("玄霜"))), "白露那边的同学关系是结果");
assert!(!hits.iter().any(|(_, predicate, _)| predicate == "客卿于"),
"与剩余词无关的关系不该进第二步结果");
let context: Vec<String> = result.graph.context_relations.iter().map(|relation| relation.predicate.clone()).collect();
assert!(context.contains(&"同门".to_string()), "两端都落在集合内的关系进第三段");
assert!(!context.contains(&"客卿于".to_string()), "只有一端落在集合内的关系不进第三段");
let events: Vec<&str> = result.graph.context_events.iter().map(|event| event.name.as_str()).collect();
assert_eq!(events, vec!["别鹤典仪"], "参与者至少两个落在集合内才算第三段的事件");
assert!(result.memories.iter().any(|hit| hit.record["judgment"].as_str().unwrap_or_default().contains("朱樱的同学")));
assert!(notes_empty(&result.notes), "RAG 不出笔记那一路");
}
#[test]
fn preset_graph_event_route_stops_at_the_character_budget() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let created = kb.graph().apply_batch(&GraphBatch {
entities: vec![entity("甲"), entity("乙"), entity("丙"), entity("丁")], ..Default::default()
}).unwrap().value;
let id = |name: &str| created.entities.iter().find(|entity| entity.name == name).unwrap().header.id;
kb.graph().apply_batch(&GraphBatch {
relations: vec![
relation_of(id("甲"), "同伴", id("乙")),
relation_of(id("甲"), "同伴", id("丙")),
relation_of(id("甲"), "同伴", id("丁")),
],
events: vec![
event_of("事件一", vec![id("甲"), id("乙")]),
event_of("很长很长很长很长的事件名", vec![id("甲"), id("丙")]),
event_of("事件三", vec![id("甲"), id("丁")]),
], ..Default::default()
}).unwrap();
let elsewhere = kb.graph().apply_batch(&GraphBatch {
events: vec![event_of("别处事件", vec![id("甲"), id("乙")])], ..Default::default()
}).unwrap().value;
{
let conn = rusqlite::Connection::open(dir.path().join("store.sqlite3")).unwrap();
conn.execute("INSERT OR IGNORE INTO strings(text) VALUES('private')", []).unwrap();
let scope_id: i64 = conn.query_row("SELECT id FROM strings WHERE text='private'", [], |row| row.get(0)).unwrap();
conn.execute("UPDATE records SET scope_id=?1 WHERE id=?2",
rusqlite::params![scope_id, elsewhere.events[0].header.id]).unwrap();
}
let result = kb.search_preset(&PresetRequest {
preset: SearchPreset::Graph, query: "甲".into(),
budget: PresetBudget { graph_context_chars: 20, ..Default::default() }, ..Default::default()
}).unwrap();
let names: Vec<&str> = result.graph.context_events.iter().map(|event| event.name.as_str()).collect();
assert_eq!(names, vec!["事件一"], "预算装不下第二条时就停在第一条,不跳过去捡后面的短事件");
}
#[test]
fn preset_graph_keeps_all_incident_relations_for_a_pure_name_query() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let created = kb.graph().apply_batch(&GraphBatch {
entities: vec![entity("墨团"), entity("派罗"), entity("月裔")], ..Default::default()
}).unwrap().value;
let id = |name: &str| created.entities.iter().find(|entity| entity.name == name).unwrap().header.id;
kb.graph().apply_batch(&GraphBatch {
relations: vec![
relation_of(id("墨团"), "朋友", id("派罗")),
relation_of(id("墨团"), "隶属于", id("月裔")),
], ..Default::default()
}).unwrap();
let result = kb.search_preset(&PresetRequest {
preset: SearchPreset::Graph, query: "墨团".into(), ..Default::default()
}).unwrap();
let predicates: Vec<&str> = result.graph.relations.iter().map(|r| r.predicate.as_str()).collect();
assert!(predicates.contains(&"朋友"), "剩余词为空时墨团的朋友关系不该被筛掉");
assert!(predicates.contains(&"隶属于"), "剩余词为空时墨团的隶属关系不该被筛掉");
}
#[test]
fn preset_graph_step2_expands_the_remainder_with_predicate_synonyms() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let created = kb.graph().apply_batch(&GraphBatch {
entities: vec![entity("alice"), entity("bob")], ..Default::default()
}).unwrap().value;
let id = |name: &str| created.entities.iter().find(|entity| entity.name == name).unwrap().header.id;
kb.graph().apply_batch(&GraphBatch {
relations: vec![relation_of(id("bob"), "alpha", id("alice"))], ..Default::default()
}).unwrap();
let before = kb.search_preset(&PresetRequest {
preset: SearchPreset::Graph, query: "alice的beta".into(), ..Default::default()
}).unwrap();
assert!(!before.graph.relations.iter().any(|r| r.predicate == "alpha"), "没登记等价词时敲不到另一写法");
kb.graph().set_predicate_equivalents("default", &[vec!["alpha".into(), "beta".into()]]).unwrap();
let after = kb.search_preset(&PresetRequest {
preset: SearchPreset::Graph, query: "alice的beta".into(), ..Default::default()
}).unwrap();
assert!(after.graph.relations.iter().any(|r| r.predicate == "alpha"),
"登记等价词后「beta」扩散出「alpha」,关系应被第二步召回");
}
fn notes_empty(section: &NoteSection) -> bool {
section.titles.is_empty() && section.contents.is_empty() && section.paths.is_empty()
}
fn notes_section(kb: &KnowledgeBase, query: &str) -> NoteSection {
kb.search_preset(&PresetRequest { preset: SearchPreset::Notes, query: query.into(), ..Default::default() }).unwrap().notes
}
#[test]
fn preset_notes_split_titles_contents_and_path_fallback() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let root = dir.path().to_string_lossy().into_owned();
kb.notes().set_root("default", &root).unwrap();
let a_dir = dir.path().join("city");
std::fs::create_dir_all(&a_dir).unwrap();
let a_path = a_dir.join("city.md");
std::fs::write(&a_path, "岩王帝君坐镇此地").unwrap();
kb.notes().upsert_file(NoteFileInput::new(&a_path)).unwrap();
let b_dir = dir.path().join("杂记");
std::fs::create_dir_all(&b_dir).unwrap();
let b_path = b_dir.join("港口见闻.md");
std::fs::write(&b_path, "city 港口 今日格外热闹").unwrap();
kb.notes().upsert_file(NoteFileInput::new(&b_path)).unwrap();
let c_dir = dir.path().join("沧浪");
std::fs::create_dir_all(&c_dir).unwrap();
let c_path = c_dir.join("寒天之钉.md");
std::fs::write(&c_path, "封冻的极北之地一览无余").unwrap();
kb.notes().upsert_file(NoteFileInput::new(&c_path)).unwrap();
let by_title = notes_section(&kb, "city");
assert!(!by_title.titles.is_empty(), "文件名命中的 A 进书名块");
assert!(!by_title.contents.is_empty(), "正文命中的 B 进内容块");
let title_ids: Vec<i64> = by_title.titles.iter().map(|hit| hit.key.id).collect();
let content_ids: Vec<i64> = by_title.contents.iter().map(|hit| hit.key.id).collect();
assert!(title_ids.iter().all(|id| !content_ids.contains(id)), "同一片不重复出现在两块");
assert!(by_title.paths.is_empty(), "已被书名或内容覆盖的目录命中不再进兜底块");
assert!(kb.search(&SearchRequest { query: "沧浪".into(), kinds: vec![RecordKind::Chunk], ..Default::default() }).unwrap().hits.is_empty(),
"目录段不参与常规匹配");
let by_path = notes_section(&kb, "沧浪");
assert!(by_path.titles.is_empty() && by_path.contents.is_empty(), "沧浪只在目录里,书名与内容都不命中");
assert!(!by_path.paths.is_empty(), "书名块不足时由目录兜底补足");
kb.rebuild_indexes().unwrap();
let after = notes_section(&kb, "city");
assert_eq!(after.titles.iter().map(|hit| hit.key.id).collect::<Vec<_>>(), title_ids, "重建后书名块一致");
assert_eq!(after.contents.iter().map(|hit| hit.key.id).collect::<Vec<_>>(), content_ids, "重建后内容块一致");
assert!(!notes_section(&kb, "沧浪").paths.is_empty(), "重建后目录兜底仍生效");
}
#[test]
fn preset_fields_stay_separate() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.memories().upsert(memory("预设字段分离用的记忆", "public")).unwrap();
kb.graph().apply_batch(&GraphBatch { entities: vec![entity("预设字段分离用的实体")], ..Default::default() }).unwrap();
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
let path = dir.path().join("预设字段分离用的笔记.md");
std::fs::write(&path, "预设字段分离用的正文".repeat(20)).unwrap();
kb.notes().upsert_file(NoteFileInput::new(&path)).unwrap();
let query = "预设字段分离用";
let of = |preset| kb.search_preset(&PresetRequest { preset, query: query.into(), ..Default::default() }).unwrap();
let memory_only = of(SearchPreset::Memory);
assert!(!memory_only.memories.is_empty());
assert!(memory_only.graph.entities.is_empty() && notes_empty(&memory_only.notes), "记忆预设只填记忆那一个字段");
let graph_only = of(SearchPreset::Graph);
assert!(graph_only.memories.is_empty() && notes_empty(&graph_only.notes), "图谱预设只填图谱那一个字段");
assert!(graph_only.graph.entities.iter().any(|entity| entity.name == "预设字段分离用的实体"));
let notes_only = of(SearchPreset::Notes);
assert!(!notes_only.notes.titles.is_empty() || !notes_only.notes.contents.is_empty(), "笔记预设出笔记结果");
assert!(notes_only.memories.is_empty() && notes_only.graph.entities.is_empty(), "笔记预设只填笔记那一个字段");
let broad = of(SearchPreset::Broad);
assert!(!broad.memories.is_empty() && !notes_empty(&broad.notes) && !broad.graph.entities.is_empty(), "广撒网三路都出");
}
#[test]
fn preset_budgets_cap_by_characters_not_counts() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
for index in 0..5 {
kb.memories().upsert(memory(&format!("字符封顶样例 {index} {}", "长".repeat(300)), "public")).unwrap();
}
let request = |chars| PresetRequest { preset: SearchPreset::Memory, query: "字符封顶样例".into(),
budget: PresetBudget { memory_chars: chars, ..Default::default() }, ..Default::default() };
assert_eq!(kb.search_preset(&request(10_000)).unwrap().memories.len(), 5, "预算够时全部返回");
assert_eq!(kb.search_preset(&request(600)).unwrap().memories.len(), 1, "三百多字的正文只装得下一条");
assert!(kb.search_preset(&request(0)).unwrap().memories.is_empty(), "预算为 0 就是空");
}
#[test]
fn preset_without_seed_entities_keeps_other_routes() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.memories().upsert(memory("只写在记忆里的独有措辞", "public")).unwrap();
let result = kb.search_preset(&PresetRequest {
preset: SearchPreset::Rag, query: "只写在记忆里的独有措辞".into(), ..Default::default()
}).unwrap();
assert!(result.graph.entities.is_empty() && result.graph.relations.is_empty() && result.graph.context_events.is_empty(),
"没有实体命中,图谱那一路整体空着");
assert!(!result.memories.is_empty(), "记忆那一路不受影响");
}
#[test]
fn entity_name_column_outweighs_long_body() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let target = kb.graph().apply_batch(&GraphBatch { entities: vec![entity("苹果")], ..Default::default() })
.unwrap().value.entities[0].header.id;
let mut noisy = entity("香蕉");
noisy.aliases = vec!["苹果".into()];
for key in ["别称", "俗称", "外号"] {
noisy.attributes.insert(key.into(), vec!["苹果".into(), "苹果".into(), "苹果".into()]);
}
let noise = kb.graph().apply_batch(&GraphBatch { entities: vec![noisy], ..Default::default() })
.unwrap().value.entities[0].header.id;
let req = SearchRequest { query: "苹果".into(), kinds: vec![RecordKind::Entity],
text: true, vector: false, rerank: false, ..Default::default() };
let hits = kb.search(&req).unwrap().hits;
assert!(hits.len() >= 2, "两条都该被召回");
assert_eq!(hits[0].key.id, target, "规范名命中应压过长正文里堆起来的词频");
assert!(hits.iter().any(|hit| hit.key.id == noise));
}
#[test]
fn entity_name_reaches_the_rerank_document() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.graph().apply_batch(&GraphBatch { entities: vec![entity("孤名实体")], ..Default::default() }).unwrap();
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = seen.clone();
kb.register_reranker_with(move |_: &str, documents: &[String]| {
recorder.lock().unwrap().extend(documents.iter().cloned());
Ok(vec![1.0f32; documents.len()])
}, RerankerOptions::default()).unwrap();
seen.lock().unwrap().clear();
let request = SearchRequest { query: "孤名实体".into(), kinds: vec![RecordKind::Entity],
text: true, vector: false, rerank: true, ..Default::default() };
let hits = kb.search(&request).unwrap().hits;
assert!(!hits.is_empty(), "纯名实体靠名字列也该被召回");
let documents = seen.lock().unwrap().clone();
assert!(documents.iter().any(|doc| doc.contains("孤名实体")),
"实体的规范名必须出现在重排文档里,实收 {documents:?}");
}
#[test]
fn predicate_equivalents_register_list_and_expand_per_domain() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
assert!(kb.graph().predicate_equivalents("demo").unwrap().is_empty(), "没登记就是空");
kb.graph().set_predicate_equivalents("demo", &[vec!["alpha".into(), "beta".into(), "gamma".into()]]).unwrap();
let groups = kb.graph().predicate_equivalents("demo").unwrap();
assert_eq!(groups, vec![vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()]], "组内按文本排序");
let expanded = kb.graph().expand_query("demo", "alice的beta").unwrap();
assert!(expanded.contains(&"alpha".to_string()) && expanded.contains(&"gamma".to_string()), "命中「beta」应展开出「alpha」「gamma」: {expanded:?}");
assert!(kb.graph().expand_query("demo", "alice").unwrap().is_empty(), "查询里没有登记词就是空");
assert!(kb.graph().expand_query("other", "alice的beta").unwrap().is_empty(), "别的领域不共享等价词");
}
#[test]
fn predicate_equivalents_expand_search_without_rewriting_storage() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let made = kb.graph().apply_batch(&GraphBatch { entities: vec![entity("bob"), entity("alice")], ..Default::default() }).unwrap();
let subject = made.value.entities[0].header.id;
let object = made.value.entities[1].header.id;
let relation = kb.graph().apply_batch(&GraphBatch { relations: vec![RelationInput {
record: RecordInput::default(), subject_id: subject, predicate: "delta".into(),
object_id: object, confidence: 0.9, reason: String::new(),
}], ..Default::default() }).unwrap().value.relations[0].header.id;
let request = SearchRequest { query: "epsilon".into(), kinds: vec![RecordKind::Relation],
text: true, vector: false, rerank: false, ..Default::default() };
assert!(kb.search(&request).unwrap().hits.is_empty(), "没登记时同义查询召回不到");
kb.graph().set_predicate_equivalents("default", &[vec!["delta".into(), "epsilon".into()]]).unwrap();
let hits = kb.search(&request).unwrap().hits;
assert_eq!(hits.len(), 1, "登记后同义查询能召回");
assert_eq!(hits[0].key.id, relation);
let stored: serde_json::Value = kb.graph().get(RecordKind::Relation, relation, &ReadFilter::default()).unwrap();
assert_eq!(stored["predicate"], serde_json::json!("delta"));
}
#[test]
fn event_sink_receives_one_search_event_with_stages() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
for text in ["事件流 甲", "事件流 乙", "事件流 丙"] { kb.memories().upsert(memory(text, "public")).unwrap(); }
let events: Arc<Mutex<Vec<LogEvent>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = events.clone();
kb.register_event_sink(move |event: &LogEvent| recorder.lock().unwrap().push(event.clone()));
assert!(kb.event_sink_registered());
let request = SearchRequest { query: "事件流".into(), limit: 3, kinds: vec![RecordKind::Memory], vector: false, ..Default::default() };
let result = kb.search(&request).unwrap();
let seen = events.lock().unwrap();
assert_eq!(seen.len(), 1, "一次检索一条事件");
let event = &seen[0];
assert_eq!(event.kind, "search");
assert!(!event.ts.is_empty());
assert_eq!(event.hits, Some(result.hits.len()));
assert_eq!(event.candidates, Some(3));
assert_eq!(event.folded, Some(3));
assert_eq!(event.rerank_docs, Some(0), "没启用重排就不送候选");
assert!(event.degraded.is_empty());
for stage in ["prepare", "text", "fuse", "fold", "load"] {
assert!(event.stages.contains_key(stage), "缺阶段 {stage}");
}
assert!(!event.stages.contains_key("vector"), "没走向量路就不该有向量格");
assert!(!event.stages.contains_key("embed"), "没走向量路就不该有嵌入格");
assert!(!event.stages.contains_key("rerank"), "没启用重排就不该有重排格");
assert!(event.ms >= event.stages.values().copied().max().unwrap_or(0), "总耗时覆盖各段");
}
#[test]
fn event_sink_reports_the_rerank_stage() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
for text in ["事件重排 甲", "事件重排 乙", "事件重排 丙"] { kb.memories().upsert(memory(text, "public")).unwrap(); }
let received: Arc<Mutex<usize>> = Arc::new(Mutex::new(0));
let recorder = received.clone();
kb.register_reranker_with(move |_: &str, documents: &[String]| {
*recorder.lock().unwrap() = documents.len();
Ok(vec![0.0f32; documents.len()])
}, RerankerOptions { max_tokens_total: 10, max_tokens_per_doc: 6, ..Default::default() }).unwrap();
*received.lock().unwrap() = 0;
let events: Arc<Mutex<Vec<LogEvent>>> = Arc::new(Mutex::new(Vec::new()));
let sink = events.clone();
kb.register_event_sink(move |event: &LogEvent| sink.lock().unwrap().push(event.clone()));
let request = SearchRequest { query: "事件重排".into(), limit: 3, kinds: vec![RecordKind::Memory], rerank: true, vector: false, ..Default::default() };
let result = kb.search(&request).unwrap();
let seen = events.lock().unwrap();
assert_eq!(seen.len(), 1);
let event = &seen[0];
assert!(event.stages.contains_key("rerank"), "启用重排就该有重排格");
assert_eq!(event.rerank_docs, Some(result.diagnostics.rerank_candidates));
assert_eq!(event.rerank_docs, Some(*received.lock().unwrap()), "事件里的文档数就是回调实收数");
assert!(event.rerank_tokens.unwrap_or(0) > 0, "token 预算里有查询词");
}
#[test]
fn event_sink_panic_does_not_break_search() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
for text in ["事件抛错 甲", "事件抛错 乙"] { kb.memories().upsert(memory(text, "public")).unwrap(); }
let request = SearchRequest { query: "事件抛错".into(), limit: 2, kinds: vec![RecordKind::Memory], vector: false, ..Default::default() };
let baseline: Vec<i64> = kb.search(&request).unwrap().hits.iter().map(|hit| hit.key.id).collect();
kb.register_event_sink(|_: &LogEvent| panic!("sink 自己炸了"));
let after = kb.search(&request).unwrap();
assert_eq!(after.hits.iter().map(|hit| hit.key.id).collect::<Vec<_>>(), baseline);
}
#[test]
fn event_sink_does_not_change_results() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
for text in ["事件中立 甲", "事件中立 乙"] { kb.memories().upsert(memory(text, "public")).unwrap(); }
let request = SearchRequest { query: "事件中立".into(), limit: 2, kinds: vec![RecordKind::Memory], vector: false, ..Default::default() };
let before = kb.search(&request).unwrap();
let events: Arc<Mutex<usize>> = Arc::new(Mutex::new(0));
let counter = events.clone();
kb.register_event_sink(move |_: &LogEvent| *counter.lock().unwrap() += 1);
let after = kb.search(&request).unwrap();
assert_eq!(*events.lock().unwrap(), 1);
assert_eq!(before.hits.iter().map(|hit| (hit.key.id, hit.score)).collect::<Vec<_>>(),
after.hits.iter().map(|hit| (hit.key.id, hit.score)).collect::<Vec<_>>());
assert!(kb.unregister_event_sink());
assert!(!kb.event_sink_registered());
kb.search(&request).unwrap();
assert_eq!(*events.lock().unwrap(), 1, "注销后不再产出");
}
#[test]
fn event_sink_receives_index_rebuild() {
let dir = tempfile::tempdir().unwrap(); let kb = KnowledgeBase::open(dir.path()).unwrap();
for text in ["事件重建 甲", "事件重建 乙"] { kb.memories().upsert(memory(text, "public")).unwrap(); }
kb.update_index().unwrap();
let events: Arc<Mutex<Vec<LogEvent>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = events.clone();
kb.register_event_sink(move |event: &LogEvent| recorder.lock().unwrap().push(event.clone()));
let report = kb.rebuild_indexes().unwrap();
let seen = events.lock().unwrap();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].kind, "index_rebuild");
assert_eq!(seen[0].documents, Some(report.index_document_count));
assert!(seen[0].format.as_deref().unwrap_or_default().starts_with("p-memory-text-"));
assert!(seen[0].stages.is_empty(), "重建事件不分阶段");
}
#[test]
fn deleted_records_leave_the_index() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let doomed = kb.memories().upsert(memory("删档独有词", "public")).unwrap().value.header.id;
kb.memories().upsert(memory("保留记录", "public")).unwrap();
kb.update_index().unwrap();
assert_eq!(kb.health().unwrap().index_document_count, 2, "两条都进了索引");
let hits = |query: &str| kb.search(&SearchRequest { query: query.into(), limit: 10, ..Default::default() }).unwrap().hits.len();
assert_eq!(hits("删档独有词"), 1);
kb.memories().delete(doomed, &ReadFilter::default()).unwrap();
kb.update_index().unwrap();
assert_eq!(kb.health().unwrap().index_document_count, 1, "删掉的记录要从索引里摘掉");
assert_eq!(hits("删档独有词"), 0, "删掉的记录不能再被搜到");
assert_eq!(hits("保留记录"), 1, "同批的其他记录不受影响");
}
#[test]
fn rewritten_note_drops_its_obsolete_chunks_from_the_index() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
let path = dir.path().join("长文.md");
std::fs::write(&path, "overview".repeat(800)).unwrap();
let wide = kb.notes().upsert_file(NoteFileInput::new(&path)).unwrap().value;
let chunks = kb.notes().chunks(wide.header.id, &ReadFilter::default()).unwrap().len();
assert!(chunks > 1, "这篇应当切成多片");
kb.update_index().unwrap();
assert_eq!(kb.health().unwrap().index_document_count, chunks, "多片都在索引里");
std::fs::write(&path, "overview").unwrap();
let narrow = kb.notes().upsert_file(NoteFileInput::new(&path)).unwrap().value;
kb.update_index().unwrap();
assert_eq!(kb.notes().chunks(narrow.header.id, &ReadFilter::default()).unwrap().len(), 1);
assert_eq!(kb.health().unwrap().index_document_count, 1, "废弃的切片要从索引里摘掉");
let hits = kb.search(&SearchRequest { query: "overview".into(), kinds: vec![RecordKind::Chunk], limit: 50, ..Default::default() }).unwrap().hits;
assert_eq!(hits.len(), 1, "同一篇折叠成一条");
assert_eq!(hits[0].note_chunks, Some(1), "旧切片不再计入这一篇的片段数");
}
fn two_notes(kb: &KnowledgeBase, dir: &std::path::Path) -> (i64, i64) {
kb.notes().set_root("default", &dir.to_string_lossy()).unwrap();
let mut first = NoteFileInput::new(dir.join("a.md"));
first.record.tags = vec!["地理".into()];
let mut second = NoteFileInput::new(dir.join("b.md"));
second.record.tags = vec!["商贸".into()];
std::fs::write(dir.join("a.md"), "harbor的契约与地契").unwrap();
std::fs::write(dir.join("b.md"), "harbor的商船与货单").unwrap();
let a = kb.notes().upsert_file(first).unwrap().value;
let b = kb.notes().upsert_file(second).unwrap().value;
kb.update_index().unwrap();
(a.header.id, b.header.id)
}
fn note_chunks(kb: &KnowledgeBase, note_id: i64) -> Vec<i64> {
kb.notes().chunks(note_id, &ReadFilter::default()).unwrap().into_iter().map(|chunk| chunk.header.id).collect()
}
fn chunk_hits(kb: &KnowledgeBase, query: &str, filter: ReadFilter) -> Vec<i64> {
kb.search(&SearchRequest { query: query.into(), kinds: vec![RecordKind::Chunk], limit: 50, filter, ..Default::default() })
.unwrap().hits.into_iter().map(|hit| hit.key.id).collect()
}
#[test]
fn search_can_be_limited_to_the_given_notes() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let (a, b) = two_notes(&kb, dir.path());
let (in_a, in_b) = (note_chunks(&kb, a), note_chunks(&kb, b));
let all = chunk_hits(&kb, "harbor", ReadFilter::default());
assert!(in_a.iter().all(|id| all.contains(id)) && in_b.iter().all(|id| all.contains(id)), "不限定范围时两篇都命中");
let scoped = chunk_hits(&kb, "harbor", ReadFilter { note_ids: vec![a], ..Default::default() });
assert!(!scoped.is_empty(), "限定到目标笔记后仍应有命中");
assert!(scoped.iter().all(|id| in_a.contains(id)), "命中必须全部落在被限定的笔记里");
let both = chunk_hits(&kb, "harbor", ReadFilter { note_ids: vec![a, b], ..Default::default() });
assert!(both.len() >= scoped.len(), "给多篇时覆盖不少于单篇");
assert!(both.iter().all(|id| in_a.contains(id) || in_b.contains(id)), "多篇之间是并集");
}
#[test]
fn omitting_the_note_limit_keeps_the_old_behaviour() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
two_notes(&kb, dir.path());
let omitted = chunk_hits(&kb, "harbor", ReadFilter::default());
let empty = chunk_hits(&kb, "harbor", ReadFilter { note_ids: vec![], ..Default::default() });
assert!(!omitted.is_empty(), "全域检索本来就该有命中");
assert_eq!(empty, omitted, "空集与不传必须逐条一致");
}
#[test]
fn the_note_limit_intersects_with_tags() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let (a, _) = two_notes(&kb, dir.path());
let same_note = chunk_hits(&kb, "harbor", ReadFilter { note_ids: vec![a], tags: vec!["地理".into()], ..Default::default() });
assert!(!same_note.is_empty(), "目标笔记自己的标签不挡命中");
let other = chunk_hits(&kb, "harbor", ReadFilter { note_ids: vec![a], tags: vec!["商贸".into()], ..Default::default() });
assert!(other.is_empty(), "标签取自另一篇时交集为空");
}
#[test]
fn a_note_without_hits_returns_empty_instead_of_an_error() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let (a, _) = two_notes(&kb, dir.path());
assert!(!chunk_hits(&kb, "商船", ReadFilter::default()).is_empty(), "这个词全域搜得到");
assert!(chunk_hits(&kb, "商船", ReadFilter { note_ids: vec![a], ..Default::default() }).is_empty(), "目标笔记里没有这个词");
assert!(chunk_hits(&kb, "harbor", ReadFilter { note_ids: vec![i64::MAX], ..Default::default() }).is_empty(), "不存在的笔记 id 给空结果");
}
#[test]
fn the_vector_path_honours_the_note_limit() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
space(&kb, "v", 4);
kb.embeddings().set_vectorization("default", "notes", true).unwrap();
let (a, b) = two_notes(&kb, dir.path());
fill(&kb, "v");
assert!(kb.embeddings().vector_ready("default", "v", "notes").unwrap(), "切片向量补齐后才走向量路");
let (in_a, in_b) = (note_chunks(&kb, a), note_chunks(&kb, b));
let all = kb.search(&vector_query("v", "harbor", vec![RecordKind::Chunk])).unwrap().hits;
assert!(all.iter().any(|hit| in_a.contains(&hit.key.id)) && all.iter().any(|hit| in_b.contains(&hit.key.id)),
"不限定范围时两篇的切片都参与打分");
let mut request = vector_query("v", "harbor", vec![RecordKind::Chunk]);
request.filter.note_ids = vec![a];
let scoped = kb.search(&request).unwrap().hits;
assert!(!scoped.is_empty(), "限定后向量路仍应有命中");
assert!(scoped.iter().all(|hit| in_a.contains(&hit.key.id)), "向量候选也必须来自被限定的笔记");
}
fn tagged_entity(name: &str, tags: &[&str]) -> EntityInput {
let mut value = entity(name);
value.record.tags = tags.iter().map(|tag| (*tag).to_string()).collect();
value
}
fn write_entity(kb: &KnowledgeBase, input: EntityInput) -> i64 {
kb.graph().apply_batch(&GraphBatch { entities: vec![input], ..Default::default() }).unwrap().value.entities[0].header.id
}
fn write_relation(kb: &KnowledgeBase, subject: i64, predicate: &str, object: i64) {
let relation = RelationInput { record: RecordInput::default(), subject_id: subject,
predicate: predicate.into(), object_id: object, confidence: 0.8, reason: String::new() };
kb.graph().apply_batch(&GraphBatch { relations: vec![relation], ..Default::default() }).unwrap();
}
#[test]
fn a_batch_delete_clears_the_whole_memory_domain() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let inputs: Vec<MemoryInput> = (1..=3).map(|i| memory(&format!("待清的记忆 {i}"), "public")).collect();
kb.memories().upsert_many(&inputs).unwrap();
assert_eq!(kb.memories().delete_by_filter(&ReadFilter::default()).unwrap().value, 3);
assert!(kb.memories().list(&PageRequest::default()).unwrap().items.is_empty(), "清空之后一条都不剩");
}
#[test]
fn a_tag_narrows_the_batch_delete() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let mut tagged = memory("带标签的记忆", "public");
tagged.record.tags = vec!["draft".into()];
kb.memories().upsert_many(&[tagged, memory("留下的记忆", "public")]).unwrap();
let filter = ReadFilter { tags: vec!["draft".into()], ..Default::default() };
assert_eq!(kb.memories().delete_by_filter(&filter).unwrap().value, 1);
let left = kb.memories().list(&PageRequest::default()).unwrap().items;
assert_eq!(left.len(), 1);
assert_eq!(left[0].judgment, "留下的记忆");
}
#[test]
fn an_empty_match_deletes_nothing_and_returns_zero() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
assert_eq!(kb.memories().delete_by_filter(&ReadFilter::default()).unwrap().value, 0);
assert_eq!(kb.graph().delete_by_filter(&ReadFilter::default()).unwrap().value, 0);
assert_eq!(kb.notes().delete_by_filter(&ReadFilter::default()).unwrap().value, 0);
}
#[test]
fn clearing_a_graph_domain_takes_its_edges_along() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let subject = write_entity(&kb, entity("甲"));
let object = write_entity(&kb, entity("乙"));
write_relation(&kb, subject, "创造", object);
assert_eq!(kb.graph().delete_by_filter(&ReadFilter::default()).unwrap().value, 3);
for kind in [RecordKind::Entity, RecordKind::Relation, RecordKind::Event] {
assert!(kb.graph().list(kind, &PageRequest::default()).unwrap().items.is_empty(), "域清空后 {kind:?} 一条不剩");
}
}
#[test]
fn a_batch_delete_is_refused_when_an_out_of_scope_edge_still_points_at_the_entity() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let subject = write_entity(&kb, tagged_entity("甲", &["keep"]));
let object = write_entity(&kb, entity("乙"));
write_relation(&kb, subject, "创造", object);
let filter = ReadFilter { tags: vec!["keep".into()], ..Default::default() };
assert!(matches!(kb.graph().delete_by_filter(&filter), Err(Error::Conflict(_))), "边还在,删实体必须被拦下");
assert!(kb.graph().get(RecordKind::Entity, subject, &ReadFilter::default()).is_ok(), "事务回滚,实体仍在");
}
#[test]
fn clearing_a_note_domain_takes_its_chunks_along() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
let path = dir.path().join("n.md");
std::fs::write(&path, "笔记正文里的独有措辞").unwrap();
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
let note = kb.notes().upsert_file(NoteFileInput::new(&path)).unwrap().value;
let chunk = kb.notes().chunks(note.header.id, &ReadFilter::default()).unwrap()[0].header.id;
assert_eq!(kb.notes().delete_by_filter(&ReadFilter::default()).unwrap().value, 1);
assert!(kb.notes().list(&PageRequest::default()).unwrap().items.is_empty());
assert!(kb.notes().get_chunk(chunk, &ReadFilter::default()).is_err(), "切片随笔记一起删掉");
}
#[test]
fn the_index_forgets_batch_deleted_records() {
let dir = tempfile::tempdir().unwrap();
let kb = KnowledgeBase::open(dir.path()).unwrap();
kb.memories().upsert(memory("批量删除后不该被检索到的措辞", "public")).unwrap();
kb.update_index().unwrap();
let query = SearchRequest { query: "批量删除后".into(), ..Default::default() };
assert!(!kb.search(&query).unwrap().hits.is_empty(), "删之前检索得到");
kb.memories().delete_by_filter(&ReadFilter::default()).unwrap();
kb.update_index().unwrap();
assert!(kb.search(&query).unwrap().hits.is_empty(), "删之后索引里也没有了");
}