use crate::auth::{AnonymousAuth, DynAuthProvider, StaticTokenAuth};
use crate::embed::{embed_one, Embedder};
use crate::error::{Error, Result};
use crate::extract::{AutoExtractor, Descriptor, Extractor};
use crate::fetch::{FetchOptions, Fetcher, HttpFetcher};
use crate::index::{Index, IndexBuilder, UpsertOutcome};
use crate::query::{PreparedQuery, RankParams};
use crate::resource::{Resource, ResourceKind, SourceRef};
use crate::source::{CrawlScope, CrawlSource, Source};
use crate::url_key::UrlKeyExt as _;
use crate::{text, Filter};
use bytes::Bytes;
use compact_str::CompactString;
use futures::stream::{FuturesUnordered, StreamExt};
use graph_r::hash::xxh3_64;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::Duration;
#[cfg(feature = "onnx")]
pub type DefaultEmbedder = crate::embed::OnnxEmbedder;
#[cfg(not(feature = "onnx"))]
pub type DefaultEmbedder = crate::embed::HashEmbedder;
#[cfg(not(feature = "onnx"))]
const HASH_DIM: usize = 256;
const DEFAULT_EMBED_BATCH: usize = 64;
const REFRESH_MAX_BYTES: u64 = 2 * 1024 * 1024;
#[cfg(feature = "onnx")]
pub fn default_embedder() -> Result<DefaultEmbedder> {
crate::embed::OnnxEmbedder::new()
}
#[cfg(not(feature = "onnx"))]
#[allow(clippy::unnecessary_wraps)] pub fn default_embedder() -> Result<DefaultEmbedder> {
Ok(crate::embed::HashEmbedder::new(HASH_DIM))
}
use graph_r::fsio::now_ms;
#[derive(Clone, Copy, Debug)]
pub struct ExportDoc<'a> {
pub meta: &'a crate::index::DocMeta,
pub edges: &'a [crate::url_key::UrlKey],
}
#[derive(Clone, Debug)]
pub struct SearchResult {
pub url: String,
pub score: f32,
pub title: Option<String>,
pub snippet: String,
pub kind: ResourceKind,
pub tags: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct UpdateReport {
pub added: usize,
pub updated: usize,
pub unchanged: usize,
pub skipped: usize,
pub failed: usize,
pub pages: Vec<PageOutcome>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PageChange {
Added,
Updated,
Unchanged,
Skipped,
Removed,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PageOutcome {
pub url: String,
pub url_key: crate::url_key::UrlKey,
pub change: PageChange,
pub title: Option<CompactString>,
pub headings: Vec<(u8, CompactString)>,
pub keywords: Vec<CompactString>,
}
impl UpdateReport {
#[must_use]
pub fn pages_seen(&self) -> usize {
self.added + self.updated + self.unchanged + self.skipped
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RefreshReport {
pub refreshed: usize,
pub unchanged: usize,
pub removed: usize,
pub failed: usize,
pub pages: Vec<PageOutcome>,
}
impl RefreshReport {
#[must_use]
pub fn total(&self) -> usize {
self.refreshed + self.unchanged + self.removed + self.failed
}
}
#[derive(Debug)]
pub struct LinkIndex<E = DefaultEmbedder> {
path: Option<PathBuf>,
embedder: E,
opened: Option<Index>,
builder: Option<IndexBuilder>,
cached: OnceLock<Index>,
http: OnceLock<reqwest::Client>,
}
impl LinkIndex {
pub fn open_or_create(path: impl Into<PathBuf>) -> Result<Self> {
Self::open_or_create_with(path, default_embedder()?)
}
pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
Self::open_with(path, default_embedder()?)
}
pub fn in_memory() -> Result<Self> {
Ok(Self::in_memory_with(default_embedder()?))
}
pub fn from_bytes(bytes: Bytes) -> Result<Self> {
Self::from_bytes_with(bytes, default_embedder()?)
}
}
impl<E: Embedder> LinkIndex<E> {
fn from_builder(path: Option<PathBuf>, embedder: E, builder: IndexBuilder) -> Self {
Self {
path,
embedder,
opened: None,
builder: Some(builder),
cached: OnceLock::new(),
http: OnceLock::new(),
}
}
fn from_opened(path: Option<PathBuf>, embedder: E, opened: Index) -> Self {
Self {
path,
embedder,
opened: Some(opened),
builder: None,
cached: OnceLock::new(),
http: OnceLock::new(),
}
}
pub fn open_or_create_with(path: impl Into<PathBuf>, embedder: E) -> Result<Self> {
let path = path.into();
if path.exists() {
let index =
check_compatible(Index::open(&path)?, &embedder, &path.display().to_string())?;
Ok(Self::from_opened(Some(path), embedder, index))
} else {
let builder =
IndexBuilder::new(embedder.dim(), embedder.metric(), embedder.identity().raw());
Ok(Self::from_builder(Some(path), embedder, builder))
}
}
pub fn open_with(path: impl Into<PathBuf>, embedder: E) -> Result<Self> {
let path = path.into();
if !path.exists() {
return Err(Error::not_found(path.display().to_string()));
}
let index = check_compatible(Index::open(&path)?, &embedder, &path.display().to_string())?;
Ok(Self::from_opened(Some(path), embedder, index))
}
#[must_use]
pub fn in_memory_with(embedder: E) -> Self {
let builder =
IndexBuilder::new(embedder.dim(), embedder.metric(), embedder.identity().raw());
Self::from_builder(None, embedder, builder)
}
pub fn from_bytes_with(bytes: Bytes, embedder: E) -> Result<Self> {
let index = check_compatible(Index::from_bytes(bytes)?, &embedder, "in-memory image")?;
Ok(Self::from_opened(None, embedder, index))
}
pub fn reload_from_bytes(&mut self, bytes: Bytes) -> Result<()> {
let index = check_compatible(Index::from_bytes(bytes)?, &self.embedder, "in-memory image")?;
self.opened = Some(index);
self.builder = None;
self.cached = OnceLock::new();
Ok(())
}
#[must_use]
pub fn embedder(&self) -> &E {
&self.embedder
}
pub fn to_bytes(&self) -> Result<Vec<u8>> {
match (&self.opened, &self.builder) {
(Some(idx), _) => idx.serialize(),
(None, Some(b)) => Ok(b.serialize()),
(None, None) => Err(Error::format("index has no content to serialize")),
}
}
pub fn freeze(&mut self) -> Result<()> {
let Some(builder) = self.builder.take() else {
return Ok(());
};
let image = Bytes::from(builder.serialize());
match Index::from_bytes(image) {
Ok(index) => {
self.opened = Some(index);
self.cached = OnceLock::new();
Ok(())
}
Err(e) => {
self.builder = Some(builder);
Err(e)
}
}
}
fn http_client(&self) -> Result<reqwest::Client> {
if let Some(c) = self.http.get() {
return Ok(c.clone());
}
let c = crate::fetch::http::default_client()?;
Ok(self.http.get_or_init(|| c).clone())
}
#[must_use]
pub fn len(&self) -> usize {
match (&self.opened, &self.builder) {
(Some(idx), _) => idx.len(),
(None, Some(b)) => b.len(),
(None, None) => 0,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn update(&mut self, url: impl Into<String>) -> UpdateBuilder<'_, E> {
UpdateBuilder::<'_, E>::new(self, url.into())
}
pub fn refresh(&mut self) -> RefreshBuilder<'_, E> {
RefreshBuilder::<'_, E>::new(self)
}
#[cfg(feature = "github")]
pub fn update_github(&mut self, spec: impl AsRef<str>) -> Result<GithubUpdateBuilder<'_, E>> {
let spec = crate::source::github::GithubSpec::parse(spec.as_ref())?;
Ok(GithubUpdateBuilder::<'_, E>::new(self, spec))
}
pub fn pin(&mut self, url_prefix: &str) -> Result<usize> {
Ok(self.materialize_builder()?.set_pinned(url_prefix, true))
}
pub fn unpin(&mut self, url_prefix: &str) -> Result<usize> {
Ok(self.materialize_builder()?.set_pinned(url_prefix, false))
}
pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
self.search_filtered(query, limit, &Filter::All).await
}
pub async fn search_filtered(
&self,
query: &str,
limit: usize,
filter: &Filter,
) -> Result<Vec<SearchResult>> {
self.search_ranked(query, limit, filter, 0.0).await
}
pub async fn search_ranked(
&self,
query: &str,
limit: usize,
filter: &Filter,
graph_boost: f32,
) -> Result<Vec<SearchResult>> {
let vector = embed_one(&self.embedder, query).await?;
let terms: Vec<CompactString> = text::normalized_tokens(query).collect();
let index = self.read_index();
let pq = PreparedQuery {
vector: &vector,
terms: &terms,
filter,
limit,
rank: RankParams {
graph_boost,
..RankParams::default()
},
};
let hits = index.search_prepared(&pq)?;
Ok(hits.iter().map(SearchResult::from_hit).collect())
}
pub fn related(&self, url: &str, k: usize) -> Result<Vec<SearchResult>> {
let key = crate::url_key::UrlKey::parse(url)?;
let index = self.read_index();
Ok(index
.related(key, k)
.iter()
.map(SearchResult::from_hit)
.collect())
}
#[allow(clippy::missing_errors_doc)]
pub fn export(&self) -> Result<impl Iterator<Item = ExportDoc<'_>>> {
let idx = self.read_index();
Ok(idx
.documents()
.iter()
.zip(idx.edge_lists())
.map(|(meta, edges)| ExportDoc { meta, edges }))
}
pub fn save(&self) -> Result<()> {
let path = self
.path
.as_ref()
.ok_or_else(|| Error::invalid_url("no path bound; use save_as"))?;
self.save_to(path)
}
pub fn save_as(&mut self, path: impl Into<PathBuf>) -> Result<()> {
let path = path.into();
self.save_to(&path)?;
self.path = Some(path);
Ok(())
}
fn save_to(&self, path: &Path) -> Result<()> {
match (&self.opened, &self.builder) {
(Some(idx), _) => idx.save(path),
(None, Some(b)) => b.save(path),
(None, None) => Err(Error::format("index has no content to save")),
}
}
fn read_index(&self) -> &Index {
if let Some(idx) = &self.opened {
return idx;
}
self.cached.get_or_init(|| {
self.builder
.as_ref()
.expect("opened or builder is always present")
.to_index()
})
}
fn materialize_builder(&mut self) -> Result<&mut IndexBuilder> {
if self.builder.is_none() {
let idx = self
.opened
.take()
.ok_or_else(|| Error::format("index has no builder or opened state"))?;
self.builder = Some(idx.into_builder()?);
}
self.cached = OnceLock::new(); Ok(self.builder.as_mut().expect("just materialized"))
}
pub async fn ingest_from<S: Source>(
&mut self,
source: &S,
root: &SourceRef,
embed_batch: usize,
pin: bool,
) -> Result<UpdateReport> {
self.materialize_builder()?;
let dim = self.embedder.dim();
let embed_batch = embed_batch.max(1);
let extractor = AutoExtractor;
let LinkIndex {
builder,
embedder,
cached,
..
} = self;
let embedder: &E = embedder;
let builder = builder.as_mut().expect("materialized");
let mut report = UpdateReport::default();
let mut buf = vec![0.0f32; embed_batch * dim];
let mut pending: Vec<(Resource, Descriptor, u64)> = Vec::with_capacity(embed_batch);
let stream = source.discover(root);
futures::pin_mut!(stream);
while let Some(item) = stream.next().await {
let page = item?;
let resource = page.resource;
let bytes = page.payload.into_bytes().await?;
let content_hash = xxh3_64(&bytes);
if builder.is_unchanged(&resource.url, content_hash) {
report.unchanged += 1;
report
.pages
.push(page_outcome(&resource.url, PageChange::Unchanged, None));
continue;
}
let descriptor = extractor.extract(&resource, &bytes)?;
if descriptor.is_empty() {
report.skipped += 1;
report
.pages
.push(page_outcome(&resource.url, PageChange::Skipped, None));
continue;
}
pending.push((resource, descriptor, content_hash));
if pending.len() == embed_batch {
flush_batch(
builder,
embedder,
&mut pending,
&mut buf,
dim,
pin,
&mut report,
)
.await?;
}
}
flush_batch(
builder,
embedder,
&mut pending,
&mut buf,
dim,
pin,
&mut report,
)
.await?;
if report.added + report.updated > 0 {
*cached = OnceLock::new();
}
Ok(report)
}
#[allow(clippy::too_many_lines)] pub async fn refresh_with<F: Fetcher>(
&mut self,
fetcher: &F,
opts: RefreshOptions,
) -> Result<RefreshReport> {
self.materialize_builder()?;
let now = now_ms();
let dim = self.embedder.dim();
let extractor = AutoExtractor;
let mut evict: Vec<url::Url> = Vec::new();
let mut stale: Vec<(url::Url, Option<CompactString>)> = Vec::new();
{
let builder = self.builder.as_ref().expect("materialized");
for d in builder.documents() {
if let Some(filter) = &opts.urls {
if !filter.contains(&d.url_key) {
continue;
}
}
let age = now.saturating_sub(d.fetched_at_ms);
if !d.pinned {
if let Some(max_age) = opts.max_age {
if u128::from(age) > max_age.as_millis() {
if let Ok(url) = url::Url::parse(&d.url) {
evict.push(url);
}
continue; }
}
}
if u128::from(age) >= opts.ttl.as_millis() {
if let Ok(url) = url::Url::parse(&d.url) {
stale.push((url, d.etag.clone()));
}
}
}
}
let mut report = RefreshReport::default();
let builder = self.builder.as_mut().expect("materialized");
let pinned_keys: std::collections::HashSet<crate::url_key::UrlKey> = builder
.documents()
.iter()
.filter(|d| d.pinned)
.map(|d| d.url_key)
.collect();
for url in &evict {
if builder.remove(url) {
report.removed += 1;
}
}
let mut pending_changed: Vec<(Resource, Descriptor, u64)> = Vec::new();
let concurrency = opts.concurrency.max(1);
let mut idx = 0;
let mut in_flight = FuturesUnordered::new();
loop {
while in_flight.len() < concurrency && idx < stale.len() {
let (url, etag) = stale[idx].clone();
idx += 1;
in_flight.push(conditional_fetch(fetcher, url, etag));
}
let Some((url, outcome)) = in_flight.next().await else {
break;
};
match outcome {
Ok(RefreshFetch::NotModified) => {
builder.touch(&url, now);
report.unchanged += 1;
report
.pages
.push(page_outcome(&url, PageChange::Unchanged, None));
}
Ok(RefreshFetch::Changed { kind, etag, bytes }) => {
let content_hash = xxh3_64(&bytes);
let mut resource = Resource::new(url.clone()).with_kind(kind);
if let Some(tag) = etag {
resource = resource.with_etag(tag);
}
let descriptor = extractor.extract(&resource, &bytes)?;
if descriptor.is_empty() {
builder.touch(&url, now);
report.unchanged += 1;
report
.pages
.push(page_outcome(&url, PageChange::Unchanged, None));
continue;
}
pending_changed.push((resource, descriptor, content_hash));
}
Err(RefreshError::Gone) => {
let pinned = pinned_keys.contains(&crate::url_key::UrlKey::from_url(&url));
if opts.evict_unreachable && !pinned && builder.remove(&url) {
report.removed += 1;
report
.pages
.push(page_outcome(&url, PageChange::Removed, None));
} else {
report.failed += 1;
}
}
Err(RefreshError::Transient) => report.failed += 1,
}
}
let mut buf = vec![0.0f32; DEFAULT_EMBED_BATCH * dim];
while !pending_changed.is_empty() {
let take = pending_changed.len().min(DEFAULT_EMBED_BATCH);
let batch: Vec<(Resource, Descriptor, u64)> = pending_changed.drain(..take).collect();
let texts: Vec<&str> = batch
.iter()
.map(|(_, d, _)| d.embed_text.as_str())
.collect();
let out = &mut buf[..texts.len() * dim];
self.embedder.embed_batch(&texts, out).await?;
drop(texts);
for (i, (resource, descriptor, content_hash)) in batch.into_iter().enumerate() {
let vector = out[i * dim..(i + 1) * dim].to_vec();
let outcome_meta = descriptor_outcome_meta(&descriptor);
let mut doc =
descriptor.into_document(resource.url, resource.kind, content_hash, vector);
doc.fetched_at_ms = now;
doc.etag = resource.etag;
let url = doc.url.clone();
if builder.upsert(doc)? == UpsertOutcome::Unchanged {
builder.touch(&url, now);
report.unchanged += 1;
report
.pages
.push(page_outcome(&url, PageChange::Unchanged, None));
} else {
report.refreshed += 1;
report
.pages
.push(page_outcome(&url, PageChange::Updated, Some(outcome_meta)));
}
}
}
if report.refreshed + report.removed > 0 {
self.cached = OnceLock::new();
}
Ok(report)
}
}
impl SearchResult {
fn from_hit(h: crate::query::Hit<'_>) -> Self {
Self {
url: h.url.to_owned(),
score: h.score,
title: h.title.map(str::to_owned),
snippet: h.snippet.to_owned(),
kind: h.kind,
tags: h.tags().map(str::to_owned).collect(),
}
}
}
async fn flush_batch<E: Embedder>(
builder: &mut IndexBuilder,
embedder: &E,
pending: &mut Vec<(Resource, Descriptor, u64)>,
buf: &mut [f32],
dim: usize,
pin: bool,
report: &mut UpdateReport,
) -> Result<()> {
if pending.is_empty() {
return Ok(());
}
let now = now_ms();
let texts: Vec<&str> = pending
.iter()
.map(|(_, d, _)| d.embed_text.as_str())
.collect();
let out = &mut buf[..texts.len() * dim];
embedder.embed_batch(&texts, out).await?;
drop(texts);
for (i, (resource, descriptor, content_hash)) in pending.drain(..).enumerate() {
let vector = out[i * dim..(i + 1) * dim].to_vec();
let outcome_meta = descriptor_outcome_meta(&descriptor);
let mut doc = descriptor.into_document(resource.url, resource.kind, content_hash, vector);
doc.fetched_at_ms = now;
doc.etag = resource.etag;
doc.pinned = pin;
let url = doc.url.clone();
let change = match builder.upsert(doc)? {
crate::index::UpsertOutcome::Added => {
report.added += 1;
PageChange::Added
}
crate::index::UpsertOutcome::Updated => {
report.updated += 1;
PageChange::Updated
}
crate::index::UpsertOutcome::Unchanged => {
report.unchanged += 1;
PageChange::Unchanged
}
};
report
.pages
.push(page_outcome(&url, change, Some(outcome_meta)));
}
Ok(())
}
type OutcomeMeta = (
Option<CompactString>,
Vec<(u8, CompactString)>,
Vec<CompactString>,
);
fn descriptor_outcome_meta(d: &Descriptor) -> OutcomeMeta {
let headings = d
.headings
.iter()
.enumerate()
.map(|(i, h)| {
let level = d
.heading_levels
.get(i)
.copied()
.unwrap_or((i.min(2) + 1) as u8);
(level, h.clone())
})
.collect();
(
d.title.clone(),
headings,
d.keywords.iter().cloned().collect(),
)
}
fn page_outcome(url: &url::Url, change: PageChange, meta: Option<OutcomeMeta>) -> PageOutcome {
let (title, headings, keywords) = meta.unwrap_or_default();
PageOutcome {
url: crate::url_key::canonicalize(url),
url_key: crate::url_key::UrlKey::from_url(url),
change,
title,
headings,
keywords,
}
}
enum RefreshFetch {
NotModified,
Changed {
kind: ResourceKind,
etag: Option<CompactString>,
bytes: Bytes,
},
}
enum RefreshError {
Gone,
Transient,
}
async fn conditional_fetch<F: Fetcher>(
fetcher: &F,
url: url::Url,
etag: Option<CompactString>,
) -> (url::Url, std::result::Result<RefreshFetch, RefreshError>) {
let resource = Resource::new(url);
let opts = FetchOptions {
if_none_match: etag.as_deref(),
max_bytes: Some(REFRESH_MAX_BYTES),
user_agent: None,
};
let outcome = match fetcher.fetch(&resource, opts).await {
Ok(got) => {
let kind = got.meta.kind;
let etag = got.meta.etag;
match got.payload.into_bytes().await {
Ok(bytes) => Ok(RefreshFetch::Changed { kind, etag, bytes }),
Err(_) => Err(RefreshError::Transient),
}
}
Err(Error::NotModified { .. }) => Ok(RefreshFetch::NotModified),
Err(
Error::NotFound { .. } | Error::PermissionDenied { .. } | Error::Unauthenticated { .. },
) => Err(RefreshError::Gone),
Err(_) => Err(RefreshError::Transient),
};
(resource.url, outcome)
}
fn check_compatible<E: Embedder>(index: Index, embedder: &E, what: &str) -> Result<Index> {
if index.dim() != embedder.dim() || index.embedder_id() != embedder.identity().raw() {
return Err(Error::embed(format!(
"index at {what} is incompatible with the current embedder (index dim {}, embedder dim {})",
index.dim(),
embedder.dim()
)));
}
Ok(index)
}
#[derive(Clone, Debug)]
pub struct RefreshOptions {
pub urls: Option<std::collections::HashSet<crate::url_key::UrlKey>>,
pub ttl: Duration,
pub max_age: Option<Duration>,
pub evict_unreachable: bool,
pub concurrency: usize,
}
impl Default for RefreshOptions {
fn default() -> Self {
Self {
urls: None,
ttl: Duration::from_secs(24 * 60 * 60),
max_age: None,
evict_unreachable: true,
concurrency: 8,
}
}
}
pub struct UpdateBuilder<'a, E = DefaultEmbedder> {
index: &'a mut LinkIndex<E>,
root: String,
depth: u16,
scope: CrawlScope,
max_pages: usize,
concurrency: usize,
max_retries: u32,
min_delay: Duration,
embed_batch: usize,
pin: bool,
require_path: Vec<CompactString>,
accept_extensions: Vec<CompactString>,
index_path: Vec<CompactString>,
#[cfg(feature = "robots")]
respect_robots: bool,
auth: Box<dyn DynAuthProvider>,
extra_validators: Vec<(crate::url_key::UrlKey, CompactString)>,
extra_known_edges: Vec<(crate::url_key::UrlKey, Vec<url::Url>)>,
}
impl<E> std::fmt::Debug for UpdateBuilder<'_, E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UpdateBuilder")
.field("root", &self.root)
.field("depth", &self.depth)
.field("max_pages", &self.max_pages)
.field("concurrency", &self.concurrency)
.finish_non_exhaustive()
}
}
impl<'a, E: Embedder> UpdateBuilder<'a, E> {
fn new(index: &'a mut LinkIndex<E>, root: String) -> Self {
Self {
index,
root,
depth: 2,
scope: CrawlScope::PathPrefix,
max_pages: 1000,
concurrency: 8,
max_retries: 2,
min_delay: Duration::ZERO,
embed_batch: DEFAULT_EMBED_BATCH,
pin: false,
require_path: Vec::new(),
accept_extensions: Vec::new(),
index_path: Vec::new(),
#[cfg(feature = "robots")]
respect_robots: false,
auth: Box::new(AnonymousAuth),
extra_validators: Vec::new(),
extra_known_edges: Vec::new(),
}
}
#[must_use]
pub fn depth(mut self, depth: u16) -> Self {
self.depth = depth;
self
}
#[must_use]
pub fn scope(mut self, scope: CrawlScope) -> Self {
self.scope = scope;
self
}
#[must_use]
pub fn max_pages(mut self, max_pages: usize) -> Self {
self.max_pages = max_pages;
self
}
#[must_use]
pub fn concurrency(mut self, concurrency: usize) -> Self {
self.concurrency = concurrency;
self
}
#[must_use]
pub fn max_retries(mut self, max_retries: u32) -> Self {
self.max_retries = max_retries;
self
}
#[must_use]
pub fn min_delay(mut self, delay: Duration) -> Self {
self.min_delay = delay;
self
}
#[must_use]
pub fn embed_batch(mut self, n: usize) -> Self {
self.embed_batch = n.max(1);
self
}
#[must_use]
pub fn pin(mut self) -> Self {
self.pin = true;
self
}
#[cfg(feature = "robots")]
#[must_use]
pub fn respect_robots(mut self, respect: bool) -> Self {
self.respect_robots = respect;
self
}
#[must_use]
pub fn require_path(mut self, substring: impl Into<CompactString>) -> Self {
self.require_path.push(substring.into());
self
}
#[must_use]
pub fn accept_extension(mut self, extension: impl Into<CompactString>) -> Self {
self.accept_extensions.push(extension.into());
self
}
#[must_use]
pub fn index_path(mut self, substring: impl Into<CompactString>) -> Self {
self.index_path.push(substring.into());
self
}
#[must_use]
pub fn validators(
mut self,
entries: impl IntoIterator<Item = (crate::url_key::UrlKey, CompactString)>,
) -> Self {
self.extra_validators.extend(entries);
self
}
#[must_use]
pub fn known_edges(
mut self,
entries: impl IntoIterator<Item = (crate::url_key::UrlKey, Vec<url::Url>)>,
) -> Self {
self.extra_known_edges.extend(entries);
self
}
#[must_use]
pub fn token(mut self, token: impl Into<String>) -> Self {
if let Ok(SourceRef::Http { root }) = SourceRef::http(&self.root) {
if let Some(host) = root.host_str() {
self.auth = Box::new(StaticTokenAuth::bearer_scoped(token, host));
return self;
}
}
self.auth = Box::new(StaticTokenAuth::bearer(token));
self
}
#[must_use]
pub fn auth(mut self, auth: Box<dyn DynAuthProvider>) -> Self {
self.auth = auth;
self
}
pub async fn run(self) -> Result<UpdateReport> {
let root = SourceRef::http(&self.root)?;
self.index.materialize_builder()?;
let (validators, known_edges) = {
let builder = self.index.builder.as_ref().expect("materialized");
let docs = builder.documents();
let by_key: std::collections::HashMap<crate::url_key::UrlKey, &str> =
docs.iter().map(|d| (d.url_key, d.url.as_str())).collect();
let mut validators = self.extra_validators;
let mut known_edges = self.extra_known_edges;
for (d, edges) in docs.iter().zip(builder.edge_lists()) {
if let Some(etag) = &d.etag {
validators.push((d.url_key, etag.clone()));
}
let children: Vec<url::Url> = edges
.iter()
.filter_map(|k| by_key.get(k))
.filter_map(|u| url::Url::parse(u).ok())
.collect();
if !children.is_empty() {
known_edges.push((d.url_key, children));
}
}
(validators, known_edges)
};
let fetcher = HttpFetcher::with_client(self.index.http_client()?, self.auth);
#[allow(unused_mut)]
let mut source = CrawlSource::new(fetcher)
.validators(validators)
.known_edges(known_edges)
.depth(self.depth)
.scope(self.scope)
.max_pages(self.max_pages)
.concurrency(self.concurrency)
.max_retries(self.max_retries)
.min_delay(self.min_delay);
for substring in self.require_path {
source = source.require_path(substring);
}
for ext in self.accept_extensions {
source = source.accept_extension(ext);
}
for substring in self.index_path {
source = source.index_path(substring);
}
#[cfg(feature = "robots")]
{
source = source.respect_robots(self.respect_robots);
}
let mut report = self
.index
.ingest_from(&source, &root, self.embed_batch, self.pin)
.await?;
report.failed = source.failed_count() as usize;
for url in source.take_revalidated() {
report.unchanged += 1;
report
.pages
.push(page_outcome(&url, PageChange::Unchanged, None));
}
Ok(report)
}
}
#[cfg(feature = "github")]
pub struct GithubUpdateBuilder<'a, E = DefaultEmbedder> {
index: &'a mut LinkIndex<E>,
spec: crate::source::github::GithubSpec,
token: Option<String>,
api_base: Option<CompactString>,
raw_base: Option<CompactString>,
depth: Option<u16>,
max_bytes: u64,
concurrency: usize,
embed_batch: usize,
pin: bool,
accept_extensions: Vec<CompactString>,
extra_validators: Vec<(crate::url_key::UrlKey, CompactString)>,
}
#[cfg(feature = "github")]
impl<E> std::fmt::Debug for GithubUpdateBuilder<'_, E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GithubUpdateBuilder")
.field("spec", &self.spec)
.field("depth", &self.depth)
.field("has_token", &self.token.is_some())
.finish_non_exhaustive()
}
}
#[cfg(feature = "github")]
impl<'a, E: Embedder> GithubUpdateBuilder<'a, E> {
fn new(index: &'a mut LinkIndex<E>, spec: crate::source::github::GithubSpec) -> Self {
Self {
index,
spec,
token: None,
api_base: None,
raw_base: None,
depth: None,
max_bytes: REFRESH_MAX_BYTES,
concurrency: 8,
embed_batch: DEFAULT_EMBED_BATCH,
pin: false,
accept_extensions: Vec::new(),
extra_validators: Vec::new(),
}
}
#[must_use]
pub fn token(mut self, token: impl Into<String>) -> Self {
self.token = Some(token.into());
self
}
#[must_use]
pub fn bases(
mut self,
api_base: impl Into<CompactString>,
raw_base: impl Into<CompactString>,
) -> Self {
self.api_base = Some(api_base.into());
self.raw_base = Some(raw_base.into());
self
}
#[must_use]
pub fn depth(mut self, depth: u16) -> Self {
self.depth = Some(depth);
self
}
#[must_use]
pub fn max_bytes(mut self, max_bytes: u64) -> Self {
self.max_bytes = max_bytes;
self
}
#[must_use]
pub fn concurrency(mut self, concurrency: usize) -> Self {
self.concurrency = concurrency.max(1);
self
}
#[must_use]
pub fn embed_batch(mut self, n: usize) -> Self {
self.embed_batch = n.max(1);
self
}
#[must_use]
pub fn pin(mut self) -> Self {
self.pin = true;
self
}
#[must_use]
pub fn accept_extension(mut self, extension: impl AsRef<str>) -> Self {
let e = extension
.as_ref()
.trim_start_matches('.')
.to_ascii_lowercase();
self.accept_extensions.push(CompactString::from(e));
self
}
#[must_use]
pub fn validators(
mut self,
entries: impl IntoIterator<Item = (crate::url_key::UrlKey, CompactString)>,
) -> Self {
self.extra_validators.extend(entries);
self
}
pub async fn run(self) -> Result<UpdateReport> {
use crate::source::github::{GitHubSource, GithubAuth};
self.index.materialize_builder()?;
let mut validators = self.extra_validators;
{
let builder = self.index.builder.as_ref().expect("materialized");
for d in builder.documents() {
if let Some(etag) = &d.etag {
validators.push((d.url_key, etag.clone()));
}
}
}
let auth = match (&self.api_base, &self.raw_base) {
(Some(api), Some(raw)) => {
let api_host = url::Url::parse(api)
.ok()
.and_then(|u| u.host_str().map(CompactString::from))
.unwrap_or_else(|| CompactString::from("api.github.com"));
let raw_host = url::Url::parse(raw)
.ok()
.and_then(|u| u.host_str().map(CompactString::from))
.unwrap_or_else(|| CompactString::from("raw.githubusercontent.com"));
GithubAuth::new(self.token).with_hosts(api_host, raw_host)
}
_ => GithubAuth::new(self.token),
};
let fetcher = HttpFetcher::with_client(self.index.http_client()?, auth);
let mut source = GitHubSource::new(fetcher, self.spec)
.max_bytes(self.max_bytes)
.concurrency(self.concurrency)
.validators(validators);
if let (Some(api), Some(raw)) = (self.api_base, self.raw_base) {
source = source.bases(api, raw);
}
if let Some(depth) = self.depth {
source = source.depth(depth);
}
for ext in self.accept_extensions {
source = source.accept_extension(ext);
}
let root = SourceRef::http("https://api.github.com/")?;
let mut report = self
.index
.ingest_from(&source, &root, self.embed_batch, self.pin)
.await?;
report.failed = source.failed_count() as usize;
for url in source.take_revalidated() {
report.unchanged += 1;
report
.pages
.push(page_outcome(&url, PageChange::Unchanged, None));
}
Ok(report)
}
}
pub struct RefreshBuilder<'a, E = DefaultEmbedder> {
index: &'a mut LinkIndex<E>,
opts: RefreshOptions,
auth: Box<dyn DynAuthProvider>,
scoped_host: Option<CompactString>,
}
impl<E> std::fmt::Debug for RefreshBuilder<'_, E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RefreshBuilder")
.field("opts", &self.opts)
.finish_non_exhaustive()
}
}
impl<'a, E: Embedder> RefreshBuilder<'a, E> {
fn new(index: &'a mut LinkIndex<E>) -> Self {
Self {
index,
opts: RefreshOptions::default(),
auth: Box::new(AnonymousAuth),
scoped_host: None,
}
}
#[must_use]
pub fn ttl(mut self, ttl: Duration) -> Self {
self.opts.ttl = ttl;
self
}
#[must_use]
pub fn max_age(mut self, max_age: Duration) -> Self {
self.opts.max_age = Some(max_age);
self
}
#[must_use]
pub fn evict_unreachable(mut self, evict: bool) -> Self {
self.opts.evict_unreachable = evict;
self
}
#[must_use]
pub fn concurrency(mut self, concurrency: usize) -> Self {
self.opts.concurrency = concurrency;
self
}
#[must_use]
pub fn urls<I>(mut self, urls: I) -> Self
where
I: IntoIterator,
I::Item: AsRef<str>,
{
let set: std::collections::HashSet<crate::url_key::UrlKey> = urls
.into_iter()
.filter_map(|u| url::Url::parse(u.as_ref()).ok())
.map(|u| crate::url_key::UrlKey::from_url(&u))
.collect();
self.opts.urls = Some(set);
self
}
#[must_use]
pub fn token(mut self, token: impl Into<String>, host: impl Into<CompactString>) -> Self {
let host = host.into();
self.auth = Box::new(StaticTokenAuth::bearer_scoped(token, host.as_str()));
self.scoped_host = Some(host);
self
}
#[must_use]
pub fn auth(mut self, auth: Box<dyn DynAuthProvider>) -> Self {
self.auth = auth;
self
}
pub async fn run(self) -> Result<RefreshReport> {
let fetcher = HttpFetcher::with_client(self.index.http_client()?, self.auth);
self.index.refresh_with(&fetcher, self.opts).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resource::Resource;
#[tokio::test]
async fn in_memory_search_over_manual_upserts() {
let mut idx = LinkIndex::in_memory().unwrap();
index_doc(&mut idx, "https://x.dev/cat", "cats are feline animals").await;
index_doc(&mut idx, "https://x.dev/dog", "dogs are canine animals").await;
let hits = idx.search("canine", 5).await.unwrap();
assert_eq!(hits[0].url, "https://x.dev/dog");
assert_eq!(idx.len(), 2);
}
#[tokio::test]
async fn save_and_reopen_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("kb.lnkr");
{
let mut idx = LinkIndex::open_or_create(&path).unwrap();
index_doc(&mut idx, "https://x.dev/a", "alpha content here").await;
idx.save().unwrap();
}
let idx = LinkIndex::open(&path).unwrap();
assert_eq!(idx.len(), 1);
assert!(idx.opened.is_some() && idx.builder.is_none());
let hits = idx.search("alpha", 5).await.unwrap();
assert_eq!(hits[0].url, "https://x.dev/a");
}
#[tokio::test]
async fn search_cache_is_built_and_invalidated() {
let mut idx = LinkIndex::in_memory().unwrap();
index_doc(&mut idx, "https://x.dev/a", "alpha").await;
assert!(idx.cached.get().is_none());
let _ = idx.search("alpha", 5).await.unwrap();
assert!(idx.cached.get().is_some());
index_doc(&mut idx, "https://x.dev/b", "beta").await;
assert!(idx.cached.get().is_none());
let hits = idx.search("beta", 5).await.unwrap();
assert_eq!(hits[0].url, "https://x.dev/b");
}
#[tokio::test]
async fn first_mutation_after_open_materializes_builder() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("kb.lnkr");
{
let mut idx = LinkIndex::open_or_create(&path).unwrap();
index_doc(&mut idx, "https://x.dev/a", "alpha").await;
idx.save().unwrap();
}
let mut idx = LinkIndex::open(&path).unwrap();
assert!(idx.opened.is_some());
idx.pin("https://x.dev/").unwrap();
assert!(idx.opened.is_none() && idx.builder.is_some());
assert_eq!(idx.len(), 1);
}
use crate::fetch::{FetchMeta, Fetched};
use crate::payload::DocPayload;
use std::collections::HashMap;
use std::time::Duration;
enum Behavior {
NotModified,
Changed(Vec<u8>),
Gone,
}
struct RefreshMock {
behavior: HashMap<String, Behavior>,
fetched: std::sync::Mutex<Vec<String>>,
}
impl RefreshMock {
fn new(pairs: Vec<(&str, Behavior)>) -> Self {
Self {
behavior: pairs.into_iter().map(|(u, b)| (u.to_string(), b)).collect(),
fetched: std::sync::Mutex::new(Vec::new()),
}
}
}
impl Fetcher for RefreshMock {
type FetchFuture<'a>
= std::future::Ready<Result<Fetched<'a>>>
where
Self: 'a;
fn fetch<'a>(
&'a self,
resource: &'a Resource,
_opts: FetchOptions<'a>,
) -> Self::FetchFuture<'a> {
let key = crate::url_key::canonicalize(&resource.url);
self.fetched.lock().unwrap().push(key.clone());
let result = match self.behavior.get(&key) {
Some(Behavior::NotModified) => Err(Error::not_modified(resource.url.as_str())),
Some(Behavior::Changed(body)) => Ok(Fetched {
meta: FetchMeta {
kind: ResourceKind::Text,
etag: None,
status: 200,
final_url: None,
},
payload: DocPayload::Owned(Bytes::from(body.clone())),
}),
Some(Behavior::Gone) | None => Err(Error::not_found(resource.url.as_str())),
};
std::future::ready(result)
}
}
#[tokio::test]
async fn refresh_touches_changes_and_evicts() {
let mut idx = LinkIndex::in_memory().unwrap();
index_doc(&mut idx, "https://x.dev/keep", "keep body unchanged").await;
index_doc(&mut idx, "https://x.dev/change", "old body").await;
index_doc(&mut idx, "https://x.dev/dead", "dead body").await;
assert_eq!(idx.len(), 3);
let fetcher = RefreshMock::new(vec![
("https://x.dev/keep", Behavior::NotModified),
(
"https://x.dev/change",
Behavior::Changed(b"brand new different content".to_vec()),
),
("https://x.dev/dead", Behavior::Gone),
]);
let opts = RefreshOptions {
urls: None,
ttl: Duration::ZERO, max_age: None,
evict_unreachable: true,
concurrency: 4,
};
let report = idx.refresh_with(&fetcher, opts).await.unwrap();
assert_eq!(report.unchanged, 1, "keep was 304");
assert_eq!(report.refreshed, 1, "change re-indexed");
assert_eq!(report.removed, 1, "dead evicted");
assert_eq!(idx.len(), 2);
let hits = idx.search("different", 5).await.unwrap();
assert!(hits.iter().any(|h| h.url == "https://x.dev/change"));
}
#[tokio::test]
async fn refresh_urls_subset_only_touches_listed() {
let mut idx = LinkIndex::in_memory().unwrap();
index_doc(&mut idx, "https://x.dev/one", "first body words").await;
index_doc(&mut idx, "https://x.dev/two", "second body words").await;
index_doc(&mut idx, "https://x.dev/three", "third body words").await;
let fetcher = RefreshMock::new(vec![
("https://x.dev/one", Behavior::NotModified),
("https://x.dev/two", Behavior::NotModified),
("https://x.dev/three", Behavior::NotModified),
]);
let opts = RefreshOptions {
urls: Some(
[crate::url_key::UrlKey::parse("https://x.dev/two").unwrap()]
.into_iter()
.collect(),
),
ttl: Duration::ZERO,
max_age: None,
evict_unreachable: true,
concurrency: 4,
};
let report = idx.refresh_with(&fetcher, opts).await.unwrap();
assert_eq!(report.total(), 1, "only the listed URL was acted on");
let requested = fetcher.fetched.lock().unwrap();
assert_eq!(&*requested, &vec!["https://x.dev/two".to_string()]);
}
#[tokio::test]
async fn export_surfaces_documents_and_edges() {
let mut idx = LinkIndex::in_memory().unwrap();
index_doc(
&mut idx,
"https://x.dev/hub",
"<a href=\"/spoke\">s</a> hub body words",
)
.await;
index_doc(&mut idx, "https://x.dev/spoke", "spoke body words").await;
let docs: Vec<_> = idx.export().unwrap().collect();
assert_eq!(docs.len(), 2);
assert!(docs.iter().any(|d| d.meta.url == "https://x.dev/hub"));
assert!(docs
.iter()
.all(|d| d.meta.url_key == crate::url_key::UrlKey::parse(&d.meta.url).unwrap()));
}
#[tokio::test]
async fn refresh_of_identical_body_without_etag_advances_freshness() {
let mut idx = LinkIndex::in_memory().unwrap();
index_doc(&mut idx, "https://x.dev/static", "stable page body words").await;
let fetcher = RefreshMock::new(vec![(
"https://x.dev/static",
Behavior::Changed(b"stable page body words".to_vec()),
)]);
let opts = RefreshOptions {
urls: None,
ttl: Duration::ZERO,
max_age: None,
evict_unreachable: true,
concurrency: 2,
};
let report = idx.refresh_with(&fetcher, opts).await.unwrap();
assert_eq!(report.unchanged, 1, "identical body counts as unchanged");
assert_eq!(report.refreshed, 0);
assert_eq!(fetcher.fetched.lock().unwrap().len(), 1);
let opts = RefreshOptions {
urls: None,
ttl: Duration::from_secs(3600),
max_age: None,
evict_unreachable: true,
concurrency: 2,
};
let report = idx.refresh_with(&fetcher, opts).await.unwrap();
assert_eq!(report.total(), 0, "no work: page is fresh now");
assert_eq!(
fetcher.fetched.lock().unwrap().len(),
1,
"no second network fetch for a fresh page"
);
}
#[tokio::test]
async fn max_age_evicts_unpinned_without_fetching() {
let mut idx = LinkIndex::in_memory().unwrap();
index_doc(&mut idx, "https://x.dev/old", "old").await; let fetcher = RefreshMock::new(vec![]);
let opts = RefreshOptions {
urls: None,
ttl: Duration::from_secs(u64::from(u32::MAX)), max_age: Some(Duration::from_millis(1)), evict_unreachable: true,
concurrency: 4,
};
let report = idx.refresh_with(&fetcher, opts).await.unwrap();
assert_eq!(report.removed, 1);
assert_eq!(idx.len(), 0);
assert!(
fetcher.fetched.lock().unwrap().is_empty(),
"max-age eviction must not fetch"
);
}
#[tokio::test]
async fn graph_related_and_boost_roundtrip() {
use crate::index::{Document, IndexBuilder};
use crate::resource::ResourceKind;
use crate::url_key::UrlKey;
fn node(url: &str, terms: &[&str], targets: &[&str]) -> Document {
let u = url::Url::parse(url).unwrap();
let edges = targets
.iter()
.map(|t| UrlKey::from_url(&url::Url::parse(t).unwrap()))
.collect();
Document {
url: u,
kind: ResourceKind::Html,
content_hash: url.len() as u64,
title: None,
snippet: "s".into(),
lang: None,
tags: smallvec::SmallVec::new(),
terms: terms.iter().map(|t| (*t).into()).collect(),
vector: vec![1.0],
edges,
fetched_at_ms: 0,
etag: None,
pinned: false,
}
}
let mut b = IndexBuilder::new(1, crate::metric::Metric::Cosine, 0);
b.upsert(node(
"https://x.dev/hub",
&["hub"],
&["https://x.dev/a", "https://x.dev/b"],
))
.unwrap();
b.upsert(node("https://x.dev/a", &["alpha"], &["https://x.dev/b"]))
.unwrap();
b.upsert(node("https://x.dev/b", &["beta"], &[])).unwrap();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("g.lnkr");
b.save(&path).unwrap();
let index = crate::index::Index::open(&path).unwrap();
let rel = index.related(
UrlKey::from_url(&url::Url::parse("https://x.dev/a").unwrap()),
5,
);
let urls: Vec<&str> = rel.iter().map(|h| h.url).collect();
assert!(urls.contains(&"https://x.dev/b"), "related: {urls:?}");
}
#[tokio::test]
async fn pinned_survives_max_age() {
let mut idx = LinkIndex::in_memory().unwrap();
index_doc(&mut idx, "https://x.dev/pinned", "x").await;
assert_eq!(idx.pin("https://x.dev/pinned").unwrap(), 1);
let fetcher = RefreshMock::new(vec![]);
let opts = RefreshOptions {
urls: None,
ttl: Duration::from_secs(u64::from(u32::MAX)),
max_age: Some(Duration::from_millis(1)),
evict_unreachable: true,
concurrency: 4,
};
let report = idx.refresh_with(&fetcher, opts).await.unwrap();
assert_eq!(report.removed, 0, "pinned exempt from max-age");
assert_eq!(idx.len(), 1);
}
async fn index_doc(idx: &mut LinkIndex, url: &str, body: &str) {
idx.materialize_builder().unwrap(); let resource = Resource::new(url::Url::parse(url).unwrap()).with_kind(ResourceKind::Text);
let descriptor = AutoExtractor.extract(&resource, body.as_bytes()).unwrap();
let vector = embed_one(&idx.embedder, &descriptor.embed_text)
.await
.unwrap();
let doc = descriptor.into_document(
resource.url,
resource.kind,
xxh3_64(body.as_bytes()),
vector,
);
idx.builder.as_mut().unwrap().upsert(doc).unwrap();
}
}