#![allow(clippy::manual_async_fn)]
pub use objstore_r::ObjectStore;
use crate::facade::{DefaultEmbedder, RefreshOptions};
use crate::remote::Remote;
use crate::source::{CrawlSource, Source};
use crate::{Embedder, Fetcher, Filter, LinkIndex, SourceRef, UrlKeyExt as _};
use compact_str::CompactString;
use futures_util::future::try_join;
use graph_r::{Config, Durability, QueryOpts, Store};
use objstore_r::mirror::Mirror;
use std::collections::HashMap;
use std::future::Future;
use std::path::Path;
use std::time::Duration;
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("graph: {0}")]
Graph(#[from] graph_r::Error),
#[error("links: {0}")]
Links(#[from] crate::Error),
#[error("graph remote: {0}")]
Mirror(#[from] objstore_r::mirror::Error),
#[error("links remote: {0}")]
Remote(#[from] crate::remote::Error),
#[error("this knowledge base is a read-only replica")]
ReadOnly,
}
#[derive(Clone, Debug)]
pub struct WriterOptions {
pub holder: String,
pub lease_ttl: Duration,
pub graph: Config,
}
impl WriterOptions {
#[must_use]
pub fn new(holder: impl Into<String>) -> Self {
Self {
holder: holder.into(),
lease_ttl: Duration::from_secs(120),
graph: Config {
durability: Durability::Never,
..Config::default()
},
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SyncReport {
pub added: usize,
pub updated: usize,
pub unchanged: usize,
pub removed: usize,
pub failed: usize,
pub graph_docs: usize,
pub uploaded: u64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct KbHit {
pub url: String,
pub title: Option<CompactString>,
pub snippet: Option<CompactString>,
pub anchor: Option<(CompactString, Option<(u32, u32)>)>,
pub score: f32,
pub graph_rank: Option<u32>,
pub link_rank: Option<u32>,
}
#[derive(Clone, Debug)]
pub struct LookupOpts {
pub limit: usize,
pub graph: QueryOpts,
pub filter: Filter,
pub graph_boost: f32,
pub graph_weight: f32,
pub link_weight: f32,
pub rrf_k: f32,
}
impl Default for LookupOpts {
fn default() -> Self {
Self {
limit: 10,
graph: QueryOpts::default(),
filter: Filter::All,
graph_boost: 0.0,
graph_weight: 1.0,
link_weight: 1.0,
rrf_k: 60.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Role {
Writer,
Replica,
}
#[derive(Debug)]
pub struct KnowledgeBase<'o, O: ObjectStore, E: Embedder = DefaultEmbedder> {
graph: Store,
links: LinkIndex<E>,
graph_remote: Mirror<'o, O>,
link_remote: Remote<'o, O>,
role: Role,
}
fn remotes<'o, O: ObjectStore>(
objects: &'o O,
prefix: &str,
) -> Result<(Mirror<'o, O>, Remote<'o, O>)> {
let p = prefix.trim_end_matches('/');
let join = |part: &str| {
if p.is_empty() {
part.to_owned()
} else {
format!("{p}/{part}")
}
};
Ok((
Mirror::new(objects, &join("graph"))?,
Remote::new(objects, &join("links"))?,
))
}
impl<'o, O: ObjectStore, E: Embedder + 'o> KnowledgeBase<'o, O, E> {
pub fn writer<'a>(
objects: &'o O,
prefix: &'a str,
dir: &'a Path,
embedder: E,
opts: WriterOptions,
) -> impl Future<Output = Result<Self>> + 'a
where
'o: 'a,
{
async move {
let (graph_remote, link_remote) = remotes(objects, prefix)?;
let ttl = u64::try_from(opts.lease_ttl.as_millis()).unwrap_or(u64::MAX);
graph_remote.acquire_lease(&opts.holder, ttl).await?;
let pull = async { graph_remote.pull_into(dir).await.map_err(Error::from) };
let load = async {
link_remote
.load_or_empty_with(embedder)
.await
.map_err(Error::from)
};
let (pulled, (links, _)) = try_join(pull, load).await?;
let graph = if pulled.is_some() || dir.join(graph_r::store::BASE_FILE).exists() {
Store::open_cfg(dir, opts.graph)?
} else {
Store::create(dir, opts.graph)?
};
graph.set_compact_inline(false);
Ok(Self {
graph,
links,
graph_remote,
link_remote,
role: Role::Writer,
})
}
}
pub fn replica<'a>(
objects: &'o O,
prefix: &'a str,
embedder: E,
) -> impl Future<Output = Result<Option<Self>>> + 'a
where
'o: 'a,
{
async move {
let (graph_remote, link_remote) = remotes(objects, prefix)?;
let graph = async { graph_remote.load_image().await.map_err(Error::from) };
let links = async { link_remote.load_with(embedder).await.map_err(Error::from) };
let (graph, links) = try_join(graph, links).await?;
let (Some(graph), Some(links)) = (graph, links) else {
return Ok(None);
};
Ok(Some(Self {
graph,
links,
graph_remote,
link_remote,
role: Role::Replica,
}))
}
}
#[must_use]
pub fn graph(&self) -> &Store {
&self.graph
}
#[must_use]
pub fn links(&self) -> &LinkIndex<E> {
&self.links
}
#[must_use]
pub fn is_replica(&self) -> bool {
self.role == Role::Replica
}
fn need_writer(&self) -> Result<()> {
if self.role == Role::Writer {
Ok(())
} else {
Err(Error::ReadOnly)
}
}
pub fn sync<'a, S: Source>(
&'a mut self,
source: &'a S,
root: &'a SourceRef,
embed_batch: usize,
) -> impl Future<Output = Result<SyncReport>> + use<'a, 'o, O, E, S> {
async move {
self.need_writer()?;
let report = self
.links
.ingest_from(source, root, embed_batch, false)
.await?;
let bridged = crate::bridge::ingest_update(&self.graph, &self.links, &report)?;
let mut out = SyncReport {
added: report.added,
updated: report.updated,
unchanged: report.unchanged,
failed: report.failed,
graph_docs: bridged.upserted + bridged.touched + bridged.tombstoned,
..SyncReport::default()
};
out.uploaded = self.publish().await?;
Ok(out)
}
}
pub fn sync_crawl<'a, F: Fetcher + 'a, C: FnOnce(CrawlSource<F>) -> CrawlSource<F> + 'a>(
&'a mut self,
fetcher: F,
url: &'a str,
configure: C,
) -> impl Future<Output = Result<SyncReport>> + use<'a, 'o, O, E, F, C> {
async move {
self.need_writer()?;
let seed = crate::bridge::crawl_seed(&self.graph);
let source = configure(
CrawlSource::new(fetcher)
.validators(seed.validators)
.known_edges(seed.known_edges),
);
let root = SourceRef::http(url)?;
let mut report = self.links.ingest_from(&source, &root, 64, false).await?;
report.failed = source.failed_count() as usize;
for u in source.take_revalidated() {
report.unchanged += 1;
report.pages.push(crate::facade::PageOutcome {
url: crate::canonicalize(&u),
url_key: crate::UrlKey::from_url(&u),
change: crate::facade::PageChange::Unchanged,
title: None,
headings: Vec::new(),
keywords: Vec::new(),
});
}
let bridged = crate::bridge::ingest_update(&self.graph, &self.links, &report)?;
let mut out = SyncReport {
added: report.added,
updated: report.updated,
unchanged: report.unchanged,
failed: report.failed,
graph_docs: bridged.upserted + bridged.touched + bridged.tombstoned,
..SyncReport::default()
};
out.uploaded = self.publish().await?;
Ok(out)
}
}
pub fn refresh_due<'a, F: Fetcher>(
&'a mut self,
fetcher: &'a F,
now_ms: u64,
max: usize,
) -> impl Future<Output = Result<SyncReport>> + use<'a, 'o, O, E, F> {
async move {
self.need_writer()?;
let due = crate::bridge::due_urls(&self.graph, now_ms, max);
if due.is_empty() {
return Ok(SyncReport::default());
}
let urls = due
.iter()
.filter_map(|u| crate::UrlKey::parse(u).ok())
.collect();
let opts = RefreshOptions {
urls: Some(urls),
ttl: Duration::ZERO,
..RefreshOptions::default()
};
let report = self.links.refresh_with(fetcher, opts).await?;
let bridged = crate::bridge::ingest_refresh(&self.graph, &self.links, &report)?;
let mut out = SyncReport {
updated: report.refreshed,
unchanged: report.unchanged,
removed: report.removed,
failed: report.failed,
graph_docs: bridged.upserted + bridged.touched + bridged.tombstoned,
..SyncReport::default()
};
out.uploaded = self.publish().await?;
Ok(out)
}
}
pub fn publish(&mut self) -> impl Future<Output = Result<u64>> + use<'_, 'o, O, E> {
async move {
self.need_writer()?;
if self.graph.compaction_due() {
self.graph.compact()?;
}
self.links.freeze()?;
let g = async {
self.graph_remote
.publish(&self.graph)
.await
.map_err(Error::from)
};
let l = async {
self.link_remote
.publish(&self.links)
.await
.map_err(Error::from)
};
let (g, l) = try_join(g, l).await?;
Ok(g.uploaded + if l.uploaded { l.len } else { 0 })
}
}
pub fn refresh(&mut self) -> impl Future<Output = Result<bool>> + use<'_, 'o, O, E> {
async move {
if self.role == Role::Writer {
return Ok(false); }
let Self {
graph,
links,
graph_remote,
link_remote,
..
} = self;
let g = async { graph_remote.refresh_image(graph).await.map_err(Error::from) };
let l = async {
link_remote
.load_if_changed_into(links)
.await
.map_err(Error::from)
};
let (g, l) = try_join(g, l).await?;
Ok(g || l)
}
}
pub fn release(&self) -> impl Future<Output = Result<()>> + '_ {
async move { Ok(self.graph_remote.release_lease().await?) }
}
pub fn lookup<'a>(
&'a self,
query: &'a str,
opts: &'a LookupOpts,
) -> impl Future<Output = Result<Vec<KbHit>>> + 'a {
async move {
let wide = opts.limit.max(1) * 3;
let links = self
.links
.search_ranked(query, wide, &opts.filter, opts.graph_boost);
let graph = async {
let snap = self.graph.snapshot();
let graph_opts = QueryOpts {
limit: wide,
..opts.graph
};
snap.query(query, &graph_opts)
.iter()
.enumerate()
.map(|(rank, h)| KbHit {
url: h.url.to_owned(),
title: h.title.map(CompactString::from),
snippet: h.snippet.map(CompactString::from),
anchor: h
.anchor
.map(|a| (CompactString::from(a.label), a.byte_range)),
score: crate::index::fuse::rrf_term(opts.graph_weight, opts.rrf_k, rank),
graph_rank: u32::try_from(rank).ok(),
link_rank: None,
})
.collect::<Vec<KbHit>>()
};
let (links, mut fused) = futures_util::future::join(links, graph).await;
let links = links?;
fused.reserve(links.len());
let by_url: HashMap<String, usize> = fused
.iter()
.enumerate()
.map(|(i, h)| (h.url.clone(), i))
.collect();
for (rank, r) in links.iter().enumerate() {
let score = crate::index::fuse::rrf_term(opts.link_weight, opts.rrf_k, rank);
if let Some(&i) = by_url.get(&r.url) {
let hit = &mut fused[i];
hit.score += score;
hit.link_rank = u32::try_from(rank).ok();
if hit.title.is_none() {
hit.title = r.title.as_deref().map(CompactString::from);
}
if hit.snippet.is_none() && !r.snippet.is_empty() {
hit.snippet = Some(CompactString::from(r.snippet.as_str()));
}
} else {
fused.push(KbHit {
url: r.url.clone(),
title: r.title.as_deref().map(CompactString::from),
snippet: (!r.snippet.is_empty())
.then(|| CompactString::from(r.snippet.as_str())),
anchor: None,
score,
graph_rank: None,
link_rank: u32::try_from(rank).ok(),
});
}
}
drop(by_url);
fused.sort_by(|a, b| b.score.total_cmp(&a.score).then_with(|| a.url.cmp(&b.url)));
fused.truncate(opts.limit);
Ok(fused)
}
}
pub fn related(&self, url: &str, k: usize) -> Result<Vec<KbHit>> {
let from_links = self.links.related(url, k * 2)?;
let snap = self.graph.snapshot();
let from_graph = snap.related(crate::bridge::key_of(url), k * 2);
let mut out: Vec<KbHit> = Vec::new();
let mut by_url: HashMap<String, usize> = HashMap::new();
for (rank, h) in from_graph.iter().enumerate() {
by_url.insert(h.url.to_owned(), out.len());
out.push(KbHit {
url: h.url.to_owned(),
title: h.title.map(CompactString::from),
snippet: h.snippet.map(CompactString::from),
anchor: h
.anchor
.map(|a| (CompactString::from(a.label), a.byte_range)),
score: crate::index::fuse::rrf_term(1.0, 60.0, rank),
graph_rank: u32::try_from(rank).ok(),
link_rank: None,
});
}
for (rank, r) in from_links.iter().enumerate() {
let s = crate::index::fuse::rrf_term(1.0, 60.0, rank);
match by_url.get(&r.url) {
Some(&i) => {
out[i].score += s;
out[i].link_rank = u32::try_from(rank).ok();
}
None => out.push(KbHit {
url: r.url.clone(),
title: r.title.as_deref().map(CompactString::from),
snippet: None,
anchor: None,
score: s,
graph_rank: None,
link_rank: u32::try_from(rank).ok(),
}),
}
}
out.sort_by(|a, b| b.score.total_cmp(&a.score).then_with(|| a.url.cmp(&b.url)));
out.truncate(k);
Ok(out)
}
}