1#![allow(clippy::manual_async_fn)]
40pub use objstore_r::ObjectStore;
41
42use crate::facade::{DefaultEmbedder, RefreshOptions};
43use crate::remote::Remote;
44use crate::source::{CrawlSource, Source};
45use crate::{Embedder, Fetcher, Filter, LinkIndex, SourceRef, UrlKeyExt as _};
46use compact_str::CompactString;
47use futures_util::future::try_join;
48use graph_r::{Config, Durability, QueryOpts, Store};
49use objstore_r::mirror::Mirror;
50use std::collections::HashMap;
51use std::future::Future;
52use std::path::Path;
53use std::time::Duration;
54
55pub type Result<T, E = Error> = std::result::Result<T, E>;
57
58#[derive(Debug, thiserror::Error)]
60#[non_exhaustive]
61pub enum Error {
62 #[error("graph: {0}")]
64 Graph(#[from] graph_r::Error),
65 #[error("links: {0}")]
67 Links(#[from] crate::Error),
68 #[error("graph remote: {0}")]
70 Mirror(#[from] objstore_r::mirror::Error),
71 #[error("links remote: {0}")]
73 Remote(#[from] crate::remote::Error),
74 #[error("this knowledge base is a read-only replica")]
76 ReadOnly,
77}
78
79#[derive(Clone, Debug)]
81pub struct WriterOptions {
82 pub holder: String,
84 pub lease_ttl: Duration,
86 pub graph: Config,
90}
91
92impl WriterOptions {
93 #[must_use]
95 pub fn new(holder: impl Into<String>) -> Self {
96 Self {
97 holder: holder.into(),
98 lease_ttl: Duration::from_secs(120),
99 graph: Config {
100 durability: Durability::Never,
101 ..Config::default()
102 },
103 }
104 }
105}
106
107#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
109pub struct SyncReport {
110 pub added: usize,
112 pub updated: usize,
114 pub unchanged: usize,
116 pub removed: usize,
118 pub failed: usize,
120 pub graph_docs: usize,
122 pub uploaded: u64,
124}
125
126#[derive(Clone, Debug, PartialEq)]
129pub struct KbHit {
130 pub url: String,
132 pub title: Option<CompactString>,
134 pub snippet: Option<CompactString>,
136 pub anchor: Option<(CompactString, Option<(u32, u32)>)>,
138 pub score: f32,
140 pub graph_rank: Option<u32>,
142 pub link_rank: Option<u32>,
144}
145
146#[derive(Clone, Debug)]
148pub struct LookupOpts {
149 pub limit: usize,
151 pub graph: QueryOpts,
153 pub filter: Filter,
155 pub graph_boost: f32,
157 pub graph_weight: f32,
159 pub link_weight: f32,
161 pub rrf_k: f32,
163}
164
165impl Default for LookupOpts {
166 fn default() -> Self {
167 Self {
168 limit: 10,
169 graph: QueryOpts::default(),
170 filter: Filter::All,
171 graph_boost: 0.0,
172 graph_weight: 1.0,
173 link_weight: 1.0,
174 rrf_k: 60.0,
175 }
176 }
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180enum Role {
181 Writer,
182 Replica,
183}
184
185#[derive(Debug)]
188pub struct KnowledgeBase<'o, O: ObjectStore, E: Embedder = DefaultEmbedder> {
189 graph: Store,
190 links: LinkIndex<E>,
191 graph_remote: Mirror<'o, O>,
192 link_remote: Remote<'o, O>,
193 role: Role,
194}
195
196fn remotes<'o, O: ObjectStore>(
197 objects: &'o O,
198 prefix: &str,
199) -> Result<(Mirror<'o, O>, Remote<'o, O>)> {
200 let p = prefix.trim_end_matches('/');
201 let join = |part: &str| {
202 if p.is_empty() {
203 part.to_owned()
204 } else {
205 format!("{p}/{part}")
206 }
207 };
208 Ok((
209 Mirror::new(objects, &join("graph"))?,
210 Remote::new(objects, &join("links"))?,
211 ))
212}
213
214impl<'o, O: ObjectStore, E: Embedder + 'o> KnowledgeBase<'o, O, E> {
215 pub fn writer<'a>(
219 objects: &'o O,
220 prefix: &'a str,
221 dir: &'a Path,
222 embedder: E,
223 opts: WriterOptions,
224 ) -> impl Future<Output = Result<Self>> + 'a
225 where
226 'o: 'a,
227 {
228 async move {
229 let (graph_remote, link_remote) = remotes(objects, prefix)?;
230 let ttl = u64::try_from(opts.lease_ttl.as_millis()).unwrap_or(u64::MAX);
231 graph_remote.acquire_lease(&opts.holder, ttl).await?;
232 let pull = async { graph_remote.pull_into(dir).await.map_err(Error::from) };
233 let load = async {
234 link_remote
235 .load_or_empty_with(embedder)
236 .await
237 .map_err(Error::from)
238 };
239 let (pulled, (links, _)) = try_join(pull, load).await?;
240 let graph = if pulled.is_some() || dir.join(graph_r::store::BASE_FILE).exists() {
241 Store::open_cfg(dir, opts.graph)?
242 } else {
243 Store::create(dir, opts.graph)?
244 };
245 graph.set_compact_inline(false);
246 Ok(Self {
247 graph,
248 links,
249 graph_remote,
250 link_remote,
251 role: Role::Writer,
252 })
253 }
254 }
255
256 pub fn replica<'a>(
260 objects: &'o O,
261 prefix: &'a str,
262 embedder: E,
263 ) -> impl Future<Output = Result<Option<Self>>> + 'a
264 where
265 'o: 'a,
266 {
267 async move {
268 let (graph_remote, link_remote) = remotes(objects, prefix)?;
269 let graph = async { graph_remote.load_image().await.map_err(Error::from) };
270 let links = async { link_remote.load_with(embedder).await.map_err(Error::from) };
271 let (graph, links) = try_join(graph, links).await?;
272 let (Some(graph), Some(links)) = (graph, links) else {
273 return Ok(None);
274 };
275 Ok(Some(Self {
276 graph,
277 links,
278 graph_remote,
279 link_remote,
280 role: Role::Replica,
281 }))
282 }
283 }
284
285 #[must_use]
287 pub fn graph(&self) -> &Store {
288 &self.graph
289 }
290
291 #[must_use]
293 pub fn links(&self) -> &LinkIndex<E> {
294 &self.links
295 }
296
297 #[must_use]
299 pub fn is_replica(&self) -> bool {
300 self.role == Role::Replica
301 }
302
303 fn need_writer(&self) -> Result<()> {
304 if self.role == Role::Writer {
305 Ok(())
306 } else {
307 Err(Error::ReadOnly)
308 }
309 }
310
311 pub fn sync<'a, S: Source>(
314 &'a mut self,
315 source: &'a S,
316 root: &'a SourceRef,
317 embed_batch: usize,
318 ) -> impl Future<Output = Result<SyncReport>> + use<'a, 'o, O, E, S> {
319 async move {
320 self.need_writer()?;
321 let report = self
322 .links
323 .ingest_from(source, root, embed_batch, false)
324 .await?;
325 let bridged = crate::bridge::ingest_update(&self.graph, &self.links, &report)?;
326 let mut out = SyncReport {
327 added: report.added,
328 updated: report.updated,
329 unchanged: report.unchanged,
330 failed: report.failed,
331 graph_docs: bridged.upserted + bridged.touched + bridged.tombstoned,
332 ..SyncReport::default()
333 };
334 out.uploaded = self.publish().await?;
335 Ok(out)
336 }
337 }
338
339 pub fn sync_crawl<'a, F: Fetcher + 'a, C: FnOnce(CrawlSource<F>) -> CrawlSource<F> + 'a>(
343 &'a mut self,
344 fetcher: F,
345 url: &'a str,
346 configure: C,
347 ) -> impl Future<Output = Result<SyncReport>> + use<'a, 'o, O, E, F, C> {
348 async move {
349 self.need_writer()?;
350 let seed = crate::bridge::crawl_seed(&self.graph);
351 let source = configure(
352 CrawlSource::new(fetcher)
353 .validators(seed.validators)
354 .known_edges(seed.known_edges),
355 );
356 let root = SourceRef::http(url)?;
357 let mut report = self.links.ingest_from(&source, &root, 64, false).await?;
358 report.failed = source.failed_count() as usize;
359 for u in source.take_revalidated() {
360 report.unchanged += 1;
361 report.pages.push(crate::facade::PageOutcome {
362 url: crate::canonicalize(&u),
363 url_key: crate::UrlKey::from_url(&u),
364 change: crate::facade::PageChange::Unchanged,
365 title: None,
366 headings: Vec::new(),
367 keywords: Vec::new(),
368 });
369 }
370 let bridged = crate::bridge::ingest_update(&self.graph, &self.links, &report)?;
371 let mut out = SyncReport {
372 added: report.added,
373 updated: report.updated,
374 unchanged: report.unchanged,
375 failed: report.failed,
376 graph_docs: bridged.upserted + bridged.touched + bridged.tombstoned,
377 ..SyncReport::default()
378 };
379 out.uploaded = self.publish().await?;
380 Ok(out)
381 }
382 }
383
384 pub fn refresh_due<'a, F: Fetcher>(
387 &'a mut self,
388 fetcher: &'a F,
389 now_ms: u64,
390 max: usize,
391 ) -> impl Future<Output = Result<SyncReport>> + use<'a, 'o, O, E, F> {
392 async move {
393 self.need_writer()?;
394 let due = crate::bridge::due_urls(&self.graph, now_ms, max);
395 if due.is_empty() {
396 return Ok(SyncReport::default());
397 }
398 let urls = due
399 .iter()
400 .filter_map(|u| crate::UrlKey::parse(u).ok())
401 .collect();
402 let opts = RefreshOptions {
403 urls: Some(urls),
404 ttl: Duration::ZERO,
405 ..RefreshOptions::default()
406 };
407 let report = self.links.refresh_with(fetcher, opts).await?;
408 let bridged = crate::bridge::ingest_refresh(&self.graph, &self.links, &report)?;
409 let mut out = SyncReport {
410 updated: report.refreshed,
411 unchanged: report.unchanged,
412 removed: report.removed,
413 failed: report.failed,
414 graph_docs: bridged.upserted + bridged.touched + bridged.tombstoned,
415 ..SyncReport::default()
416 };
417 out.uploaded = self.publish().await?;
418 Ok(out)
419 }
420 }
421
422 pub fn publish(&mut self) -> impl Future<Output = Result<u64>> + use<'_, 'o, O, E> {
426 async move {
427 self.need_writer()?;
428 if self.graph.compaction_due() {
429 self.graph.compact()?;
430 }
431 self.links.freeze()?;
432 let g = async {
433 self.graph_remote
434 .publish(&self.graph)
435 .await
436 .map_err(Error::from)
437 };
438 let l = async {
439 self.link_remote
440 .publish(&self.links)
441 .await
442 .map_err(Error::from)
443 };
444 let (g, l) = try_join(g, l).await?;
445 Ok(g.uploaded + if l.uploaded { l.len } else { 0 })
446 }
447 }
448
449 pub fn refresh(&mut self) -> impl Future<Output = Result<bool>> + use<'_, 'o, O, E> {
452 async move {
453 if self.role == Role::Writer {
454 return Ok(false); }
456 let Self {
457 graph,
458 links,
459 graph_remote,
460 link_remote,
461 ..
462 } = self;
463 let g = async { graph_remote.refresh_image(graph).await.map_err(Error::from) };
464 let l = async {
465 link_remote
466 .load_if_changed_into(links)
467 .await
468 .map_err(Error::from)
469 };
470 let (g, l) = try_join(g, l).await?;
471 Ok(g || l)
472 }
473 }
474
475 pub fn release(&self) -> impl Future<Output = Result<()>> + '_ {
477 async move { Ok(self.graph_remote.release_lease().await?) }
478 }
479
480 pub fn lookup<'a>(
484 &'a self,
485 query: &'a str,
486 opts: &'a LookupOpts,
487 ) -> impl Future<Output = Result<Vec<KbHit>>> + 'a {
488 async move {
489 let wide = opts.limit.max(1) * 3;
490 let links = self
494 .links
495 .search_ranked(query, wide, &opts.filter, opts.graph_boost);
496 let graph = async {
497 let snap = self.graph.snapshot();
498 let graph_opts = QueryOpts {
499 limit: wide,
500 ..opts.graph
501 };
502 snap.query(query, &graph_opts)
503 .iter()
504 .enumerate()
505 .map(|(rank, h)| KbHit {
506 url: h.url.to_owned(),
507 title: h.title.map(CompactString::from),
508 snippet: h.snippet.map(CompactString::from),
509 anchor: h
510 .anchor
511 .map(|a| (CompactString::from(a.label), a.byte_range)),
512 score: crate::index::fuse::rrf_term(opts.graph_weight, opts.rrf_k, rank),
513 graph_rank: u32::try_from(rank).ok(),
514 link_rank: None,
515 })
516 .collect::<Vec<KbHit>>()
517 };
518 let (links, mut fused) = futures_util::future::join(links, graph).await;
519 let links = links?;
520 fused.reserve(links.len());
521 let by_url: HashMap<String, usize> = fused
522 .iter()
523 .enumerate()
524 .map(|(i, h)| (h.url.clone(), i))
525 .collect();
526 for (rank, r) in links.iter().enumerate() {
527 let score = crate::index::fuse::rrf_term(opts.link_weight, opts.rrf_k, rank);
528 if let Some(&i) = by_url.get(&r.url) {
529 let hit = &mut fused[i];
530 hit.score += score;
531 hit.link_rank = u32::try_from(rank).ok();
532 if hit.title.is_none() {
533 hit.title = r.title.as_deref().map(CompactString::from);
534 }
535 if hit.snippet.is_none() && !r.snippet.is_empty() {
536 hit.snippet = Some(CompactString::from(r.snippet.as_str()));
537 }
538 } else {
539 fused.push(KbHit {
540 url: r.url.clone(),
541 title: r.title.as_deref().map(CompactString::from),
542 snippet: (!r.snippet.is_empty())
543 .then(|| CompactString::from(r.snippet.as_str())),
544 anchor: None,
545 score,
546 graph_rank: None,
547 link_rank: u32::try_from(rank).ok(),
548 });
549 }
550 }
551 drop(by_url);
552 fused.sort_by(|a, b| b.score.total_cmp(&a.score).then_with(|| a.url.cmp(&b.url)));
553 fused.truncate(opts.limit);
554 Ok(fused)
555 }
556 }
557
558 pub fn related(&self, url: &str, k: usize) -> Result<Vec<KbHit>> {
561 let from_links = self.links.related(url, k * 2)?;
562 let snap = self.graph.snapshot();
563 let from_graph = snap.related(crate::bridge::key_of(url), k * 2);
564 let mut out: Vec<KbHit> = Vec::new();
565 let mut by_url: HashMap<String, usize> = HashMap::new();
566 for (rank, h) in from_graph.iter().enumerate() {
567 by_url.insert(h.url.to_owned(), out.len());
568 out.push(KbHit {
569 url: h.url.to_owned(),
570 title: h.title.map(CompactString::from),
571 snippet: h.snippet.map(CompactString::from),
572 anchor: h
573 .anchor
574 .map(|a| (CompactString::from(a.label), a.byte_range)),
575 score: crate::index::fuse::rrf_term(1.0, 60.0, rank),
576 graph_rank: u32::try_from(rank).ok(),
577 link_rank: None,
578 });
579 }
580 for (rank, r) in from_links.iter().enumerate() {
581 let s = crate::index::fuse::rrf_term(1.0, 60.0, rank);
582 match by_url.get(&r.url) {
583 Some(&i) => {
584 out[i].score += s;
585 out[i].link_rank = u32::try_from(rank).ok();
586 }
587 None => out.push(KbHit {
588 url: r.url.clone(),
589 title: r.title.as_deref().map(CompactString::from),
590 snippet: None,
591 anchor: None,
592 score: s,
593 graph_rank: None,
594 link_rank: u32::try_from(rank).ok(),
595 }),
596 }
597 }
598 out.sort_by(|a, b| b.score.total_cmp(&a.score).then_with(|| a.url.cmp(&b.url)));
599 out.truncate(k);
600 Ok(out)
601 }
602}