#[cfg(feature = "html")]
pub mod html;
pub mod markdown;
use crate::error::Result;
use crate::resource::{Resource, ResourceKind};
use crate::text;
use crate::url_key::UrlKeyExt as _;
use compact_str::CompactString;
use smallvec::SmallVec;
use std::collections::HashMap;
const MAX_BM25_TERMS: usize = 4096;
const MAX_KEYWORDS: usize = 8;
const MIN_KEYWORD_LEN: usize = 3;
const SNIPPET_CHARS: usize = 180;
const MAX_EDGES_PER_DOC: usize = 64;
const EMBED_BODY_CHARS: usize = 1000;
const MAX_HEADINGS: usize = 8;
#[derive(Clone, Debug, Default)]
pub struct Descriptor {
pub title: Option<CompactString>,
pub keywords: SmallVec<[CompactString; MAX_KEYWORDS]>,
pub headings: SmallVec<[CompactString; MAX_HEADINGS]>,
pub heading_levels: SmallVec<[u8; MAX_HEADINGS]>,
pub tags: SmallVec<[CompactString; 8]>,
pub lang: Option<CompactString>,
pub snippet: CompactString,
pub embed_text: String,
pub bm25_terms: Vec<CompactString>,
pub links: Vec<CompactString>,
}
impl Descriptor {
#[must_use]
pub fn is_empty(&self) -> bool {
self.bm25_terms.is_empty()
}
#[must_use]
pub fn into_document(
self,
url: url::Url,
kind: ResourceKind,
content_hash: u64,
vector: Vec<f32>,
) -> crate::index::Document {
let mut seen =
std::collections::HashSet::with_capacity(self.links.len().min(MAX_EDGES_PER_DOC));
let mut edges: Vec<crate::url_key::UrlKey> = Vec::new();
for href in &self.links {
let Ok(target) = url.join(href) else { continue };
if target.scheme() != "http" && target.scheme() != "https" {
continue;
}
let key = crate::url_key::UrlKey::from_url(&target);
if seen.insert(key) {
edges.push(key);
if edges.len() == MAX_EDGES_PER_DOC {
break;
}
}
}
edges.sort_unstable();
crate::index::Document {
url,
kind,
content_hash,
title: self.title,
snippet: self.snippet,
lang: self.lang,
tags: self.tags.into_iter().take(4).collect(),
terms: self.bm25_terms,
vector,
edges,
fetched_at_ms: 0,
etag: None,
pinned: false,
}
}
}
#[must_use]
pub fn distill(
resource: &Resource,
title: Option<CompactString>,
headings: &[CompactString],
body: &str,
links: Vec<CompactString>,
lang: Option<CompactString>,
) -> Descriptor {
let mut bm25_terms: Vec<CompactString> = Vec::new();
if let Some(t) = &title {
bm25_terms.extend(text::normalized_tokens(t));
}
for h in headings {
bm25_terms.extend(text::normalized_tokens(h));
}
bm25_terms.extend(text::normalized_tokens(body).take(MAX_BM25_TERMS));
bm25_terms.truncate(MAX_BM25_TERMS);
let mut freq: HashMap<CompactString, u32> = HashMap::new();
for term in &bm25_terms {
if term.len() >= MIN_KEYWORD_LEN && !text::is_stopword(term) {
*freq.entry(term.clone()).or_insert(0) += 1;
}
}
let mut ranked: Vec<(CompactString, u32)> = freq.into_iter().collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let keywords: SmallVec<[CompactString; MAX_KEYWORDS]> = ranked
.into_iter()
.take(MAX_KEYWORDS)
.map(|(t, _)| t)
.collect();
let tags = path_tags(resource);
let snippet = make_snippet(body);
let heading_vec: SmallVec<[CompactString; MAX_HEADINGS]> =
headings.iter().take(MAX_HEADINGS).cloned().collect();
let mut embed_text = String::new();
if let Some(t) = &title {
embed_text.push_str(t);
embed_text.push(' ');
}
for h in &heading_vec {
embed_text.push_str(h);
embed_text.push(' ');
}
for kw in &keywords {
embed_text.push_str(kw);
embed_text.push(' ');
}
for tag in &tags {
embed_text.push_str(tag);
embed_text.push(' ');
}
push_char_prefix(&mut embed_text, body, EMBED_BODY_CHARS);
Descriptor {
title,
keywords,
headings: heading_vec,
heading_levels: SmallVec::new(),
tags,
lang,
snippet,
embed_text,
bm25_terms,
links,
}
}
fn path_tags(resource: &Resource) -> SmallVec<[CompactString; 8]> {
let mut tags: SmallVec<[CompactString; 8]> = SmallVec::new();
for seg in resource.url.path().split('/') {
let seg = seg.trim();
if seg.is_empty() {
continue;
}
let base = seg.rsplit_once('.').map_or(seg, |(b, _)| b);
if base.is_empty() {
continue;
}
let norm = CompactString::from(text::normalize(base));
if norm.len() >= 2 && !tags.contains(&norm) {
tags.push(norm);
}
}
tags.truncate(8);
tags
}
fn make_snippet(body: &str) -> CompactString {
let mut snippet = String::with_capacity(SNIPPET_CHARS);
let mut prev_space = false;
for ch in body.chars() {
if snippet.chars().count() >= SNIPPET_CHARS {
break;
}
if ch.is_whitespace() {
if !prev_space && !snippet.is_empty() {
snippet.push(' ');
prev_space = true;
}
} else {
snippet.push(ch);
prev_space = false;
}
}
CompactString::from(snippet.trim())
}
fn push_char_prefix(out: &mut String, body: &str, max_chars: usize) {
let mut prev_space = false;
let mut count = 0;
for ch in body.chars() {
if count >= max_chars {
break;
}
if ch.is_whitespace() {
if !prev_space {
out.push(' ');
prev_space = true;
count += 1;
}
} else {
out.push(ch);
prev_space = false;
count += 1;
}
}
}
pub trait Extractor: Send + Sync {
fn handles(&self, kind: ResourceKind) -> bool;
fn extract(&self, resource: &Resource, bytes: &[u8]) -> Result<Descriptor>;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct AutoExtractor;
impl Extractor for AutoExtractor {
fn handles(&self, _kind: ResourceKind) -> bool {
true
}
fn extract(&self, resource: &Resource, bytes: &[u8]) -> Result<Descriptor> {
match resource.kind {
#[cfg(feature = "html")]
ResourceKind::Html => html::extract(resource, bytes),
ResourceKind::Markdown => markdown::extract_markdown(resource, bytes),
ResourceKind::Code => markdown::extract_code(resource, bytes),
_ => markdown::extract_plain(resource, bytes),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use url::Url;
fn resource(url: &str) -> Resource {
Resource::new(Url::parse(url).unwrap())
}
#[test]
fn distill_extracts_keywords_and_snippet() {
let r = resource("https://x.dev/docs/guide.md");
let body = "Kubernetes Autopilot clusters run Kubernetes without node management. \
Autopilot manages nodes for you.";
let d = distill(
&r,
Some(CompactString::from("GKE Autopilot")),
&[],
body,
Vec::new(),
None,
);
assert!(d.keywords.iter().any(|k| k == "autopilot"));
assert!(d.keywords.iter().any(|k| k == "kubernetes"));
assert!(!d.keywords.iter().any(|k| k == "for" || k == "you"));
assert!(!d.snippet.is_empty());
assert!(d.embed_text.contains("GKE Autopilot"));
}
#[test]
fn distill_derives_path_tags() {
let r = resource("https://x.dev/docs/networking/psc.md");
let d = distill(&r, None, &[], "private service connect", Vec::new(), None);
assert!(d.tags.iter().any(|t| t == "docs"));
assert!(d.tags.iter().any(|t| t == "networking"));
assert!(d.tags.iter().any(|t| t == "psc")); }
#[test]
fn empty_body_is_empty_descriptor() {
let r = resource("https://x.dev/empty");
let d = distill(&r, None, &[], " ", Vec::new(), None);
assert!(d.is_empty());
}
#[test]
fn into_document_carries_fields() {
let r = resource("https://x.dev/a");
let d = distill(
&r,
Some(CompactString::from("T")),
&[],
"cat dog cat",
Vec::new(),
None,
);
let doc = d.into_document(r.url.clone(), ResourceKind::Markdown, 99, vec![0.1, 0.2]);
assert_eq!(doc.content_hash, 99);
assert_eq!(doc.title.as_deref(), Some("T"));
assert!(doc.terms.contains(&CompactString::from("cat")));
}
}