Skip to main content

link_r/
kb.rs

1// Author: support@lituus.io
2// Copyright: lituus-io, all rights reserved
3//
4//! One knowledge base (feature `kb`) from the three libraries, on one object store:
5//!
6//! - **link-r** acquires and ranks: crawling and conditional re-crawls,
7//!   extraction, hybrid dense + BM25 search.
8//! - **graph-r** remembers and serves: the historical link graph, heading
9//!   anchors, adaptive freshness (what is due for revalidation), token-budgeted
10//!   local lookups.
11//! - **objstore-r** makes both durable on Google Cloud Storage (or a local
12//!   directory, or memory) through objstore-r's [`mirror`](objstore_r::mirror) and this crate's
13//!   [`remote`](crate::remote).
14//!
15//! ```text
16//! {prefix}/graph/…   graph-r store (HEAD + base + WAL segments)
17//! {prefix}/links/…   link-r index (HEAD + .lnkr image)
18//! ```
19//!
20//! A **writer** ([`KnowledgeBase::writer`]) holds the graph's lease, keeps the
21//! graph in a local directory (tmpfs on a stateless instance; durability is
22//! the bucket), syncs sources through the bridge and publishes both halves
23//! concurrently, fenced. A **replica** ([`KnowledgeBase::replica`]) needs no
24//! disk at all: the graph base and the link index are served in place from
25//! the fetched bytes, and [`KnowledgeBase::refresh`] costs two metadata
26//! requests when nothing changed.
27//!
28//! [`KnowledgeBase::lookup`] fuses graph-r's anchored, graph-aware lookup with
29//! link-r's hybrid search by reciprocal-rank fusion — no network.
30//!
31//! Futures are unboxed (`impl Future` over borrowed state); no `Arc`, no
32//! `dyn`, no spawned tasks: concurrency is joined futures on the caller's
33//! task.
34
35// House style: public operations return explicit `impl Future + 'a` over borrowed
36// state (the lifetime contract is in the signature), never `async fn`.
37
38// Named futures keep `+ use<..>` capture lists explicit (edition 2021).
39#![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
55/// Result alias.
56pub type Result<T, E = Error> = std::result::Result<T, E>;
57
58/// What can go wrong.
59#[derive(Debug, thiserror::Error)]
60#[non_exhaustive]
61pub enum Error {
62    /// The graph store.
63    #[error("graph: {0}")]
64    Graph(#[from] graph_r::Error),
65    /// The link index.
66    #[error("links: {0}")]
67    Links(#[from] crate::Error),
68    /// Publishing or loading the graph.
69    #[error("graph remote: {0}")]
70    Mirror(#[from] objstore_r::mirror::Error),
71    /// Publishing or loading the link index.
72    #[error("links remote: {0}")]
73    Remote(#[from] crate::remote::Error),
74    /// The operation needs the writer role.
75    #[error("this knowledge base is a read-only replica")]
76    ReadOnly,
77}
78
79/// How a writer runs.
80#[derive(Clone, Debug)]
81pub struct WriterOptions {
82    /// Lease holder name (e.g. the instance id).
83    pub holder: String,
84    /// Lease length; every publish renews it.
85    pub lease_ttl: Duration,
86    /// Local graph store configuration. Defaults to no fsync and no inline
87    /// compaction: durability is the bucket, and [`KnowledgeBase::publish`]
88    /// compacts when due, off the commit path.
89    pub graph: Config,
90}
91
92impl WriterOptions {
93    /// Defaults for `holder`.
94    #[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/// What a sync or refresh did.
108#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
109pub struct SyncReport {
110    /// Pages added to the link index.
111    pub added: usize,
112    /// Pages updated.
113    pub updated: usize,
114    /// Pages revalidated unchanged (a 304 or an identical body).
115    pub unchanged: usize,
116    /// Pages removed.
117    pub removed: usize,
118    /// Pages that failed to fetch.
119    pub failed: usize,
120    /// Graph documents written through the bridge.
121    pub graph_docs: usize,
122    /// Bytes uploaded by the publish that followed.
123    pub uploaded: u64,
124}
125
126/// One fused lookup answer. Owned and small: a URL, where to look in it, and
127/// why it ranked.
128#[derive(Clone, Debug, PartialEq)]
129pub struct KbHit {
130    /// Canonical URL.
131    pub url: String,
132    /// Title, if known.
133    pub title: Option<CompactString>,
134    /// Distilled snippet, if known.
135    pub snippet: Option<CompactString>,
136    /// Best heading anchor (label, byte range) from the graph, if any.
137    pub anchor: Option<(CompactString, Option<(u32, u32)>)>,
138    /// Fused score (higher is better).
139    pub score: f32,
140    /// Rank in the graph lookup (0-based), if it appeared there.
141    pub graph_rank: Option<u32>,
142    /// Rank in the link-r hybrid search (0-based), if it appeared there.
143    pub link_rank: Option<u32>,
144}
145
146/// Lookup knobs.
147#[derive(Clone, Debug)]
148pub struct LookupOpts {
149    /// Hits returned.
150    pub limit: usize,
151    /// graph-r lookup options (depth, seeds, token budget).
152    pub graph: QueryOpts,
153    /// link-r metadata prefilter.
154    pub filter: Filter,
155    /// Link-graph boost for link-r's ranking (0 = off).
156    pub graph_boost: f32,
157    /// Weights of the two rankings in the fusion.
158    pub graph_weight: f32,
159    /// See `graph_weight`.
160    pub link_weight: f32,
161    /// Reciprocal-rank-fusion constant.
162    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/// graph-r + link-r as one knowledge base on an object store. See the crate
186/// docs.
187#[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    /// Become the writer: take the graph's lease, hydrate the graph into
216    /// `dir` and the link index into memory concurrently (or start empty when
217    /// nothing is published yet).
218    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    /// Open a replica served from memory: the graph base and the link index
257    /// are used in place from the fetched bytes (fetched concurrently). `None`
258    /// until both halves have been published.
259    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    /// The graph store (snapshots, traversal, due-lists).
286    #[must_use]
287    pub fn graph(&self) -> &Store {
288        &self.graph
289    }
290
291    /// The link index (search, related, export).
292    #[must_use]
293    pub fn links(&self) -> &LinkIndex<E> {
294        &self.links
295    }
296
297    /// True for a replica.
298    #[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    /// Ingest from any link-r [`Source`], absorb the result into the graph
312    /// through the bridge, and publish.
313    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    /// Crawl `url` with `fetcher` — conditionally: validators and known links
340    /// come from the graph, so unchanged pages cost a 304 and no body — then
341    /// absorb and publish. `configure` tunes the crawl (depth, scope, limits).
342    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    /// Revalidate what the graph says is due (at most `max` pages, adaptive
385    /// per-page intervals), conditionally, then absorb and publish.
386    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    /// Settle and publish both halves concurrently: compact the graph if due,
423    /// freeze the link index into its zero-copy image, then ship. Returns the
424    /// bytes uploaded (0 when nothing changed). Renews the lease.
425    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    /// Replica refresh: both halves concurrently, each one metadata request
450    /// when unchanged. Returns whether anything new became visible.
451    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); // the writer is the source of truth
455            }
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    /// Give the writer lease up (so another instance can take over at once).
476    pub fn release(&self) -> impl Future<Output = Result<()>> + '_ {
477        async move { Ok(self.graph_remote.release_lease().await?) }
478    }
479
480    /// Fused lookup: graph-r's anchored, graph-aware ranking and link-r's
481    /// hybrid dense + BM25 ranking, combined by weighted reciprocal-rank
482    /// fusion on the canonical URL. Local only — no network.
483    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            // Joined, links first: its first poll sends the query embedding
491            // (a network call for a remote embedder), then the graph lookup
492            // runs while that is in flight.
493            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    /// Documents related to `url`: link-graph neighbours from link-r and
559    /// graph-r's related set, fused. Local only.
560    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}