use std::sync::Arc;
use tokio::sync::mpsc::UnboundedSender;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::app::events::{AppEvent, RagProgress};
use crate::entities::attachment::AttachMode;
use crate::features::tools::rag::ChunkParams;
use crate::shared::api::{EmbedRole, Embedder};
use crate::shared::i18n::Locale;
use crate::shared::storage::Storage;
use crate::shared::storage::db::{Db, ReembedPending, ReembedRow};
use super::Orchestrator;
use super::attachments::{AttachIndex, index_attachment};
use super::rag::EMBED_BATCH_CHUNKS;
impl Orchestrator {
pub(super) fn handle_reindex(&mut self) {
let cancel = self.reset_rag_cancel();
spawn_reembed(Reembed {
embedder: self.engines.embedder(),
storage: self.storage.clone(),
params: ChunkParams::from_settings(&self.config.rag),
cancel,
loc: self.ui_locale(),
evt_tx: self.evt_tx.clone(),
});
}
}
struct Reembed {
embedder: Arc<dyn Embedder>,
storage: Arc<Storage>,
params: ChunkParams,
cancel: CancellationToken,
loc: &'static Locale,
evt_tx: UnboundedSender<AppEvent>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Store {
Notes,
Attachments,
Rag,
}
impl Store {
const ALL: [Store; 3] = [Store::Notes, Store::Attachments, Store::Rag];
fn label(self, loc: &'static Locale) -> &'static str {
match self {
Store::Notes => loc.t("ui.reindex.store.notes"),
Store::Attachments => loc.t("ui.reindex.store.attachments"),
Store::Rag => loc.t("ui.reindex.store.knowledge_base"),
}
}
fn fetch(self, db: &Db, limit: usize) -> anyhow::Result<Vec<ReembedRow>> {
match self {
Store::Notes => db.notes_to_reembed(limit),
Store::Attachments => db.attachment_rows_to_reembed(limit),
Store::Rag => db.rag_rows_to_reembed(limit),
}
}
fn write(self, db: &Db, row: &ReembedRow, embedding: &[f32]) -> anyhow::Result<()> {
match self {
Store::Notes => db.note_vector_upsert(row.id, row.partition, embedding),
Store::Attachments => db.attachment_set_vector(row.rowid, row.partition, embedding),
Store::Rag => db.rag_set_vector(row.rowid, row.partition, embedding),
}
}
fn pending(self, p: &ReembedPending) -> usize {
match self {
Store::Notes => p.notes,
Store::Attachments => p.attachments,
Store::Rag => p.rag,
}
}
}
struct Drained {
errors: usize,
fatal: Option<String>,
}
impl Drained {
fn absorb(self, errors: &mut usize) -> Result<(), String> {
*errors += self.errors;
match self.fatal {
Some(message) => Err(message),
None => Ok(()),
}
}
}
struct Plan {
pending: ReembedPending,
missing: Vec<MissingIndex>,
}
impl Plan {
fn total(&self) -> usize {
self.pending.notes + self.pending.attachments + self.pending.rag + self.missing.len()
}
}
#[derive(Default)]
struct Counters {
done: usize,
rows: usize,
}
struct MissingIndex {
chat_id: Uuid,
attachment_id: Uuid,
}
fn spawn_reembed(task: Reembed) {
let evt_tx = task.evt_tx.clone();
tokio::spawn(async move {
let outcome = match run_reembed(task).await {
Ok(done) => done,
Err(message) => RagProgress::Failed(message),
};
let _ = evt_tx.send(AppEvent::RagProgress(outcome));
});
}
async fn run_reembed(task: Reembed) -> Result<RagProgress, String> {
let Reembed {
embedder,
storage,
params,
cancel,
loc,
evt_tx,
} = task;
let plan = plan_reembed(&embedder, &storage, loc).await?;
let total = plan.total();
if total == 0 {
return Ok(RagProgress::Reembedded {
rows: 0,
errors: 0,
cancelled: false,
});
}
let _ = evt_tx.send(AppEvent::RagProgress(RagProgress::Started { total }));
let mut counters = Counters::default();
let mut errors = 0usize;
if !plan.missing.is_empty() {
backfill_attachments(
&plan.missing,
&embedder,
&storage,
params,
&cancel,
loc,
total,
&mut counters,
&evt_tx,
)
.await
.absorb(&mut errors)?;
}
for store in Store::ALL {
if cancel.is_cancelled() || store.pending(&plan.pending) == 0 {
continue;
}
drain_store(
store,
&embedder,
&storage,
&cancel,
loc,
total,
&mut counters,
&evt_tx,
)
.await
.absorb(&mut errors)?;
}
lift_stale_marks(&storage);
Ok(RagProgress::Reembedded {
rows: counters.rows,
errors,
cancelled: cancel.is_cancelled(),
})
}
async fn plan_reembed(
embedder: &Arc<dyn Embedder>,
storage: &Arc<Storage>,
loc: &'static Locale,
) -> Result<Plan, String> {
let dim = embedder
.embed(vec!["ping".into()], EmbedRole::Passage)
.await
.map_err(|err| {
loc.tf(
"ui.err.rag_embedder_unavailable",
&[("err", &err.to_string())],
)
})?
.first()
.map_or(0, Vec::len);
if dim == 0 {
return Err(loc.t("ui.err.rag_empty_vector").into());
}
let current_dim = storage.db().rag_dimension().unwrap_or(None);
if matches!(current_dim, Some(d) if d != dim) {
storage
.db()
.drop_vector_tables()
.map_err(|err| loc.tf("ui.err.rag_reset_vectors", &[("err", &err.to_string())]))?;
}
let pending = storage
.db()
.count_rows_to_reembed()
.map_err(|err| loc.tf("ui.err.rag_read_kb", &[("err", &err.to_string())]))?;
let missing = {
let storage = storage.clone();
tokio::task::spawn_blocking(move || scan_missing_attachments(&storage))
.await
.unwrap_or_else(|err| {
tracing::warn!(error = %err, "reindex: the attachment scan panicked");
Vec::new()
})
};
Ok(Plan { pending, missing })
}
fn lift_stale_marks(storage: &Storage) {
let drained = storage
.db()
.count_rows_to_reembed()
.map(|p| p.rag == 0)
.unwrap_or(false);
if drained && let Err(err) = storage.db().set_rag_stale_profiles(&[]) {
tracing::warn!(error = %err, "failed to clear the stale knowledge-base marks");
}
}
#[allow(clippy::too_many_arguments)] async fn drain_store(
store: Store,
embedder: &Arc<dyn Embedder>,
storage: &Arc<Storage>,
cancel: &CancellationToken,
loc: &'static Locale,
total: usize,
counters: &mut Counters,
evt_tx: &UnboundedSender<AppEvent>,
) -> Drained {
let mut errors = 0usize;
loop {
if cancel.is_cancelled() {
break;
}
let rows = match store.fetch(storage.db(), EMBED_BATCH_CHUNKS) {
Ok(rows) => rows,
Err(err) => {
tracing::warn!(?store, error = %err, "re-embed: failed to read the work queue");
errors += 1;
break;
}
};
if rows.is_empty() {
break;
}
let texts: Vec<String> = rows.iter().map(|r| r.text.clone()).collect();
let embeddings = match embedder.embed(texts, EmbedRole::Passage).await {
Ok(v) if v.len() == rows.len() => v,
Ok(_) => {
return Drained {
errors,
fatal: Some(loc.t("ui.err.rag_wrong_vector_count").into()),
};
}
Err(err) => {
return Drained {
errors,
fatal: Some(loc.tf(
"ui.err.rag_embedder_unavailable",
&[("err", &err.to_string())],
)),
};
}
};
let (written, write_errors) = write_batch(store, storage, &rows, embeddings, counters);
errors += write_errors;
if written == 0 {
break;
}
let _ = evt_tx.send(AppEvent::RagProgress(RagProgress::Indexing {
index: counters.done,
total,
name: store.label(loc).to_string(),
dir: String::new(),
chunks_done: 0,
chunks_total: 0,
}));
}
Drained {
errors,
fatal: None,
}
}
fn scan_missing_attachments(storage: &Storage) -> Vec<MissingIndex> {
let files = match storage.json().chat_files() {
Ok(files) => files,
Err(err) => {
tracing::warn!(error = %format!("{err:#}"), "reindex: cannot list the chat files");
return Vec::new();
}
};
let mut missing = Vec::new();
for file in files {
let chat = match storage.json().load_chat(file.id) {
Ok(Some(chat)) => chat,
Ok(None) => continue,
Err(err) => {
tracing::warn!(chat = %file.id, error = %format!("{err:#}"),
"reindex: skipped a chat whose file could not be read");
continue;
}
};
if chat.is_hidden || !chat.attachments.iter().any(is_by_reference) {
continue;
}
let known = match storage.db().attachment_known_ids(chat.id) {
Ok(ids) => ids,
Err(err) => {
tracing::warn!(chat = %chat.id, error = %err,
"reindex: cannot read which attachments are indexed");
continue;
}
};
missing.extend(
chat.attachments
.iter()
.filter(|a| is_by_reference(a) && !known.contains(&a.id))
.map(|a| MissingIndex {
chat_id: chat.id,
attachment_id: a.id,
}),
);
}
missing
}
fn is_by_reference(attachment: &crate::entities::attachment::Attachment) -> bool {
attachment.mode == AttachMode::ByReference
}
#[allow(clippy::too_many_arguments)] async fn backfill_attachments(
missing: &[MissingIndex],
embedder: &Arc<dyn Embedder>,
storage: &Arc<Storage>,
params: ChunkParams,
cancel: &CancellationToken,
loc: &'static Locale,
total: usize,
counters: &mut Counters,
evt_tx: &UnboundedSender<AppEvent>,
) -> Drained {
let mut loaded: Option<crate::entities::chat::Chat> = None;
for item in missing {
if cancel.is_cancelled() {
break;
}
if loaded.as_ref().is_none_or(|c| c.id != item.chat_id) {
loaded = storage
.json()
.load_chat(item.chat_id)
.unwrap_or_else(|err| {
tracing::warn!(chat = %item.chat_id, error = %format!("{err:#}"),
"reindex: cannot reread a chat to rebuild its attachment index");
None
});
}
let Some((chat, attachment)) = loaded.as_ref().and_then(|chat| {
chat.attachments
.iter()
.find(|a| a.id == item.attachment_id)
.map(|a| (chat, a))
}) else {
counters.done += 1;
continue;
};
let index = AttachIndex {
chat_id: item.chat_id,
attachment_id: attachment.id,
name: attachment.name.clone(),
source: attachment.source.clone(),
text: attachment.text.clone(),
};
let title = chat.title.clone();
let name = index.name.clone();
let at = counters.done;
let outcome = index_attachment(
embedder,
storage,
params,
&index,
loc,
cancel,
|chunks_done, chunks_total| {
let _ = evt_tx.send(AppEvent::RagProgress(RagProgress::Indexing {
index: at,
total,
name: name.clone(),
dir: title.clone(),
chunks_done,
chunks_total,
}));
},
)
.await;
counters.done += 1;
match outcome {
Ok(rows) => counters.rows += rows,
Err(reason) => {
tracing::warn!(chat = %item.chat_id, attachment = %index.name, reason = %reason,
"reindex: failed to rebuild an attachment index");
return Drained {
errors: 1,
fatal: Some(reason),
};
}
}
}
Drained {
errors: 0,
fatal: None,
}
}
fn write_batch(
store: Store,
storage: &Arc<Storage>,
rows: &[ReembedRow],
embeddings: Vec<Vec<f32>>,
counters: &mut Counters,
) -> (usize, usize) {
let mut written = 0usize;
let mut errors = 0usize;
for (row, embedding) in rows.iter().zip(embeddings) {
match store.write(storage.db(), row, &embedding) {
Ok(()) => {
written += 1;
counters.done += 1;
counters.rows += 1;
}
Err(err) => {
errors += 1;
tracing::warn!(?store, rowid = row.rowid, error = %err, "re-embed: failed to write a vector");
}
}
}
(written, errors)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entities::attachment::{AttachMode, Attachment, AttachmentChunk};
use crate::entities::chat::Chat;
use crate::entities::note::Note;
use crate::entities::profile::Profile;
use crate::entities::rag::RagDocument;
use crate::shared::api::mock::MockEmbedder;
use crate::shared::i18n::{Lang, locale};
use crate::shared::paths::Paths;
use uuid::Uuid;
fn deps() -> (tempfile::TempDir, Arc<Storage>) {
let dir = tempfile::tempdir().unwrap();
let storage = Arc::new(Storage::open(Paths::with_root(dir.path())).unwrap());
(dir, storage)
}
fn run(
storage: &Arc<Storage>,
embedder: Arc<dyn Embedder>,
cancel: CancellationToken,
) -> tokio::sync::mpsc::UnboundedReceiver<AppEvent> {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
spawn_reembed(Reembed {
embedder,
storage: storage.clone(),
params: ChunkParams::default(),
cancel,
loc: locale(Lang::Ru),
evt_tx: tx,
});
rx
}
async fn finish(rx: tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> RagProgress {
collect(rx)
.await
.pop()
.expect("the job must report an outcome")
}
async fn collect(mut rx: tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> Vec<RagProgress> {
let mut seen = Vec::new();
while let Some(AppEvent::RagProgress(p)) = rx.recv().await {
let terminal = matches!(p, RagProgress::Reembedded { .. } | RagProgress::Failed(_));
seen.push(p);
if terminal {
break;
}
}
seen
}
fn seed_and_retire(storage: &Arc<Storage>, profile: Uuid) {
let note = Note::new(profile, "a note about brevity", vec![]);
storage.db().note_insert(¬e).unwrap();
storage
.db()
.note_vector_upsert(note.id, profile, &[1.0; 16])
.unwrap();
storage
.db()
.rag_insert(&RagDocument::new(
profile,
"kb.txt",
"a chunk",
vec![1.0; 16],
))
.unwrap();
storage.db().set_rag_stale_profiles(&[profile]).unwrap();
storage.db().bump_embed_generation().unwrap();
}
#[tokio::test]
async fn reembeds_every_store_and_lifts_the_stale_mark() {
let (_d, storage) = deps();
let profile = Uuid::new_v4();
seed_and_retire(&storage, profile);
assert!(storage.db().rag_is_stale(profile).unwrap());
let rx = run(
&storage,
Arc::new(MockEmbedder::new(16)),
CancellationToken::new(),
);
match finish(rx).await {
RagProgress::Reembedded {
rows,
errors,
cancelled,
} => {
assert_eq!(rows, 2, "one note + one chunk");
assert_eq!(errors, 0);
assert!(!cancelled);
}
other => panic!("expected Reembedded, got {other:?}"),
}
let pending = storage.db().count_rows_to_reembed().unwrap();
assert_eq!(pending, ReembedPending::default(), "the queue is drained");
assert!(
!storage.db().rag_is_stale(profile).unwrap(),
"a fully re-embedded base is no longer stale"
);
}
fn seed_chat_with_attachment(
storage: &Arc<Storage>,
text: &str,
mode: AttachMode,
hidden: bool,
) -> (Chat, Attachment) {
let profile = Profile::new("A", "sys");
storage.json().upsert_profile(&profile).unwrap();
let mut chat = Chat::from_profile(&profile, "a chat with a file");
let attachment = Attachment::new("report.md", "/old/report.md", text.to_string(), 42, mode);
chat.attachments.push(attachment.clone());
chat.is_hidden = hidden;
storage.json().save_chat(&chat).unwrap();
(chat, attachment)
}
#[tokio::test]
async fn an_attachment_with_no_rows_is_rebuilt_from_the_chat_file() {
let (_d, storage) = deps();
let text = format!(
"лунная база строится в 2031 году. {}",
"ещё текст. ".repeat(300)
);
let (chat, attachment) =
seed_chat_with_attachment(&storage, &text, AttachMode::ByReference, false);
assert!(
storage
.db()
.attachment_indexed_ids(chat.id)
.unwrap()
.is_empty()
);
let rx = run(
&storage,
Arc::new(MockEmbedder::new(16)),
CancellationToken::new(),
);
let seen = collect(rx).await;
assert!(
matches!(seen.first(), Some(RagProgress::Started { total: 1 })),
"one file is one unit of work: {seen:?}"
);
match seen.last() {
Some(RagProgress::Reembedded {
rows,
errors,
cancelled,
}) => {
assert!(
*rows > 1,
"and many vectors — the closing note counts those, not the units: {rows}"
);
assert_eq!(*errors, 0);
assert!(!cancelled);
}
other => panic!("expected Reembedded, got {other:?}"),
}
assert_eq!(
storage.db().attachment_indexed_ids(chat.id).unwrap(),
vec![attachment.id],
"the file is searchable again"
);
let query = Arc::new(MockEmbedder::new(16))
.embed(vec!["лунная база".into()], EmbedRole::Query)
.await
.unwrap()
.pop()
.unwrap();
let hits = storage.db().attachment_search(chat.id, &query, 3).unwrap();
assert!(
hits.iter().any(|h| h.text.contains("лунная база")),
"and the text really is in the index: {hits:?}"
);
}
#[tokio::test]
async fn inline_attachments_and_hidden_chats_are_left_alone() {
for (mode, hidden) in [(AttachMode::Inline, false), (AttachMode::ByReference, true)] {
let (_d, storage) = deps();
let (chat, _) = seed_chat_with_attachment(&storage, "какой-то текст", mode, hidden);
let rx = run(
&storage,
Arc::new(MockEmbedder::new(16)),
CancellationToken::new(),
);
assert_eq!(
finish(rx).await,
RagProgress::Reembedded {
rows: 0,
errors: 0,
cancelled: false
},
"{mode:?}, hidden={hidden}: nothing to do"
);
assert!(
storage
.db()
.attachment_known_ids(chat.id)
.unwrap()
.is_empty()
);
}
}
#[tokio::test]
async fn only_the_by_reference_half_of_a_mixed_chat_is_rebuilt() {
let (_d, storage) = deps();
let profile = Profile::new("A", "sys");
storage.json().upsert_profile(&profile).unwrap();
let mut chat = Chat::from_profile(&profile, "a chat with two files");
let inline = Attachment::new(
"small.txt",
"/old/small.txt",
"короткий текст целиком в запросе".to_string(),
10,
AttachMode::Inline,
);
let by_ref = Attachment::new(
"big.md",
"/old/big.md",
"длинный текст, который читают по ссылке".to_string(),
99,
AttachMode::ByReference,
);
chat.attachments.push(inline.clone());
chat.attachments.push(by_ref.clone());
storage.json().save_chat(&chat).unwrap();
let rx = run(
&storage,
Arc::new(MockEmbedder::new(16)),
CancellationToken::new(),
);
assert!(matches!(
finish(rx).await,
RagProgress::Reembedded { errors: 0, .. }
));
assert_eq!(
storage.db().attachment_known_ids(chat.id).unwrap(),
vec![by_ref.id],
"the inline file must not have been indexed alongside it"
);
}
#[tokio::test]
async fn a_file_whose_rows_are_only_stale_stays_with_the_re_embed_queue() {
let (_d, storage) = deps();
let (chat, attachment) = seed_chat_with_attachment(
&storage,
"первый фрагмент. второй фрагмент.",
AttachMode::ByReference,
false,
);
storage
.db()
.attachment_insert(&AttachmentChunk::new(
chat.id,
attachment.id,
&attachment.name,
"первый фрагмент",
vec![1.0; 16],
))
.unwrap();
storage.db().bump_embed_generation().unwrap();
assert!(
storage
.db()
.attachment_indexed_ids(chat.id)
.unwrap()
.is_empty(),
"not searchable"
);
assert!(
scan_missing_attachments(&storage).is_empty(),
"but not missing either — the re-embed queue owns it"
);
let rx = run(
&storage,
Arc::new(MockEmbedder::new(16)),
CancellationToken::new(),
);
match finish(rx).await {
RagProgress::Reembedded { rows, errors, .. } => {
assert_eq!(rows, 1, "the one existing row, re-embedded once");
assert_eq!(errors, 0);
}
other => panic!("expected Reembedded, got {other:?}"),
}
let query = Arc::new(MockEmbedder::new(16))
.embed(vec!["первый фрагмент".into()], EmbedRole::Query)
.await
.unwrap()
.pop()
.unwrap();
let hits = storage.db().attachment_search(chat.id, &query, 5).unwrap();
assert_eq!(hits.len(), 1, "still exactly one row: {hits:?}");
assert_eq!(hits[0].text, "первый фрагмент");
}
#[tokio::test]
async fn an_attachment_gone_since_the_scan_is_simply_skipped() {
let (_d, storage) = deps();
let (mut chat, _) = seed_chat_with_attachment(
&storage,
"текст, который сейчас исчезнет",
AttachMode::ByReference,
false,
);
let missing = scan_missing_attachments(&storage);
assert_eq!(missing.len(), 1);
chat.attachments.clear();
storage.json().save_chat(&chat).unwrap();
let mut counters = Counters::default();
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let drained = backfill_attachments(
&missing,
&(Arc::new(MockEmbedder::new(16)) as Arc<dyn Embedder>),
&storage,
ChunkParams::default(),
&CancellationToken::new(),
locale(Lang::Ru),
1,
&mut counters,
&tx,
)
.await;
assert_eq!(drained.errors, 0);
assert!(drained.fatal.is_none());
assert_eq!(counters.rows, 0, "nothing was rebuilt");
assert_eq!(
counters.done, 1,
"but the unit is spent, so the banner still reaches its total"
);
}
#[tokio::test]
async fn nothing_to_do_reports_zero_without_touching_anything() {
let (_d, storage) = deps();
let rx = run(
&storage,
Arc::new(MockEmbedder::new(16)),
CancellationToken::new(),
);
assert_eq!(
collect(rx).await,
vec![RagProgress::Reembedded {
rows: 0,
errors: 0,
cancelled: false
}]
);
}
#[test]
fn absorb_counts_errors_and_stops_on_fatal() {
let mut errors = 0;
let quiet = Drained {
errors: 2,
fatal: None,
};
assert_eq!(quiet.absorb(&mut errors), Ok(()));
assert_eq!(errors, 2);
let fatal = Drained {
errors: 1,
fatal: Some("embedder gone".into()),
};
assert_eq!(fatal.absorb(&mut errors), Err("embedder gone".into()));
assert_eq!(errors, 3);
}
#[tokio::test]
async fn a_dead_embedder_fails_the_job_without_stamping() {
let (_d, storage) = deps();
let profile = Uuid::new_v4();
seed_and_retire(&storage, profile);
let before = storage.db().count_rows_to_reembed().unwrap();
let rx = run(
&storage,
Arc::new(crate::shared::api::UnavailableEmbedder),
CancellationToken::new(),
);
assert!(matches!(finish(rx).await, RagProgress::Failed(_)));
assert_eq!(
storage.db().count_rows_to_reembed().unwrap(),
before,
"a failed job must leave the queue untouched, so a rerun redoes it"
);
assert!(
storage.db().rag_is_stale(profile).unwrap(),
"and must not lift the stale mark"
);
}
#[tokio::test]
async fn cancelled_before_start_leaves_the_stale_mark() {
let (_d, storage) = deps();
let profile = Uuid::new_v4();
seed_and_retire(&storage, profile);
let cancel = CancellationToken::new();
cancel.cancel();
let rx = run(&storage, Arc::new(MockEmbedder::new(16)), cancel);
match finish(rx).await {
RagProgress::Reembedded { cancelled, .. } => assert!(cancelled),
other => panic!("expected a cancelled Reembedded, got {other:?}"),
}
assert!(
storage.db().rag_is_stale(profile).unwrap(),
"an interrupted run must keep search refused — the base is still mixed"
);
}
#[tokio::test]
#[ignore = "requires two live embedding servers (MINDFORK_EMBED_URL, MINDFORK_EMBED_URL_ALT)"]
async fn reindex_restores_retrieval_after_a_model_swap_live() {
let (Some(a), Some(b)) = (
crate::shared::api::live_client("MINDFORK_EMBED_URL", "MINDFORK_EMBED_KEY"),
crate::shared::api::live_client("MINDFORK_EMBED_URL_ALT", "MINDFORK_EMBED_KEY_ALT"),
) else {
eprintln!("skip: MINDFORK_EMBED_URL / MINDFORK_EMBED_URL_ALT not set");
return;
};
let model_a: Arc<dyn Embedder> = Arc::new(a);
let model_b: Arc<dyn Embedder> = Arc::new(b);
let (_d, storage) = deps();
let profile = Uuid::new_v4();
const PARIS: &str = "The capital of France is Paris, the country's largest city.";
let corpus = [
PARIS,
"The cat sat on the windowsill and watched the rain.",
"Rust is a systems programming language focused on safety.",
];
const QUERY: &str = "Which city is the capital of France?";
let vectors = model_a
.embed(
corpus.iter().map(|s| s.to_string()).collect(),
EmbedRole::Passage,
)
.await
.unwrap();
for (text, vector) in corpus.iter().zip(vectors) {
storage
.db()
.rag_insert(&RagDocument::new(profile, "kb.txt", *text, vector))
.unwrap();
}
let note = Note::new(profile, "the user prefers concise answers", vec![]);
storage.db().note_insert(¬e).unwrap();
let note_vec = model_a
.embed(vec![note.content.clone()], EmbedRole::Passage)
.await
.unwrap()
.remove(0);
storage
.db()
.note_vector_upsert(note.id, profile, ¬e_vec)
.unwrap();
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let guard = |inner: Arc<dyn Embedder>, name: &str| {
super::super::embed_guard::EmbedGuard::new(
inner,
storage.clone(),
Some(name.to_string()),
crate::shared::embed_prefix::EmbedConvention::None,
locale(Lang::Ru),
tx.clone(),
)
};
guard(model_a, "bge-m3")
.embed(vec!["warm up".into()], EmbedRole::Passage)
.await
.unwrap();
guard(model_b.clone(), "e5-large-instruct")
.embed(vec!["warm up".into()], EmbedRole::Passage)
.await
.unwrap();
assert!(
storage.db().rag_is_stale(profile).unwrap(),
"the swap must mark the knowledge base stale"
);
let before = storage.db().count_rows_to_reembed().unwrap();
assert_eq!(before.rag, corpus.len(), "every chunk is queued");
assert_eq!(before.notes, 1, "the note is queued too");
let rx = run(&storage, model_b.clone(), CancellationToken::new());
match finish(rx).await {
RagProgress::Reembedded {
rows,
errors,
cancelled,
} => {
assert_eq!(rows, corpus.len() + 1);
assert_eq!(errors, 0);
assert!(!cancelled);
}
other => panic!("expected Reembedded, got {other:?}"),
}
assert_eq!(
storage.db().count_rows_to_reembed().unwrap(),
ReembedPending::default()
);
assert!(!storage.db().rag_is_stale(profile).unwrap());
let query_vec = model_b
.embed(vec![QUERY.into()], EmbedRole::Query)
.await
.unwrap()
.remove(0);
let hits = storage.db().rag_search(profile, &query_vec, 3).unwrap();
assert_eq!(
hits.first().map(|h| h.chunk_text.as_str()),
Some(PARIS),
"after re-embedding, the correct chunk ranks first again: {hits:?}"
);
let note_query = model_b
.embed(vec!["how should I answer?".into()], EmbedRole::Passage)
.await
.unwrap()
.remove(0);
assert_eq!(
storage
.db()
.note_search_semantic(profile, ¬e_query, 5)
.unwrap()
.len(),
1,
"the note's vector was rewritten, so semantic recall sees it again"
);
}
#[tokio::test]
async fn a_dimension_change_is_handled_in_the_same_loop() {
let (_d, storage) = deps();
let profile = Uuid::new_v4();
storage
.db()
.rag_insert(&RagDocument::new(
profile,
"kb.txt",
"a chunk",
vec![1.0; 16],
))
.unwrap();
storage.db().bump_embed_generation().unwrap();
assert_eq!(storage.db().rag_dimension().unwrap(), Some(16));
let rx = run(
&storage,
Arc::new(MockEmbedder::new(32)),
CancellationToken::new(),
);
match finish(rx).await {
RagProgress::Reembedded { rows, errors, .. } => {
assert_eq!(rows, 1);
assert_eq!(errors, 0, "a dimension change is not an error");
}
other => panic!("expected Reembedded, got {other:?}"),
}
assert_eq!(
storage.db().rag_dimension().unwrap(),
Some(32),
"the vector tables were rebuilt at the new width"
);
let q = vec![1.0; 32];
assert_eq!(storage.db().rag_search(profile, &q, 5).unwrap().len(), 1);
}
}