use serde::Serialize;
use std::{collections::BTreeMap, fs, path::Path};
use tracing::info;
const SHARD_COUNT: usize = 16;
#[derive(Serialize, Debug)]
struct DocMeta {
id: usize,
lang: String,
title: String,
url: String,
snippet: String,
}
#[derive(Serialize, Debug)]
struct Shard {
index: BTreeMap<String, Vec<usize>>,
}
#[derive(Serialize)]
struct SearchMeta {
docs: Vec<DocMeta>,
shards: Vec<String>, }
pub fn write_index(
out: &Path,
pages: &std::collections::BTreeMap<String, crate::site::MultiPage>,
) -> anyhow::Result<()> {
let mut metas: Vec<DocMeta> = Vec::new();
let mut shards: Vec<Shard> = (0..SHARD_COUNT)
.map(|_| Shard {
index: BTreeMap::new(),
})
.collect();
let mut doc_id = 0usize;
for mp in pages.values() {
for (lang, page) in &mp.pages {
let text = strip_html(&page.body);
let snippet: String = text.chars().take(120).collect();
metas.push(DocMeta {
id: doc_id,
lang: lang.clone(),
title: page.title.clone(),
url: mp.page_path.clone(),
snippet,
});
for token in tokenize(&text) {
let shard_idx = shard_for(&token);
let entry = shards[shard_idx].index.entry(token).or_default();
if entry.last() != Some(&doc_id) {
entry.push(doc_id);
}
}
doc_id += 1;
}
}
let mut shard_names: Vec<String> = Vec::with_capacity(SHARD_COUNT);
for (i, shard) in shards.iter().enumerate() {
let name = format!("search_i_{:02}.json", i);
let json = serde_json::to_string(&shard.index)?;
fs::write(out.join(&name), &json)?;
shard_names.push(name);
}
let meta = SearchMeta {
docs: metas,
shards: shard_names,
};
let meta_json = serde_json::to_string(&meta)?;
fs::write(out.join("search_meta.json"), &meta_json)?;
info!("search index: {} docs in {} shards", doc_id, SHARD_COUNT);
Ok(())
}
fn tokenize(text: &str) -> Vec<String> {
let mut tokens = Vec::new();
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
if ch.is_ascii_alphanumeric() {
let mut word = String::new();
while i < chars.len() {
let c = chars[i];
if c.is_ascii_alphanumeric() {
word.push(c.to_ascii_lowercase());
i += 1;
} else {
break;
}
}
if word.len() >= 2 {
tokens.push(word);
}
} else if is_cjk(ch) {
if i + 1 < chars.len() && is_cjk(chars[i + 1]) {
let bigram: String = [ch, chars[i + 1]].iter().collect();
tokens.push(bigram);
}
i += 1;
} else {
i += 1;
}
}
tokens
}
fn is_cjk(ch: char) -> bool {
matches!(
ch,
'\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{F900}'..='\u{FAFF}' | '\u{3040}'..='\u{30FF}' | '\u{AC00}'..='\u{D7AF}' )
}
fn shard_for(word: &str) -> usize {
let first = word.chars().next().unwrap_or('?');
(first as usize) % SHARD_COUNT
}
fn strip_html(html: &str) -> String {
let mut out = String::with_capacity(html.len() / 2);
let mut in_tag = false;
for ch in html.chars() {
match ch {
'<' => in_tag = true,
'>' => {
in_tag = false;
out.push(' ');
}
_ if !in_tag => out.push(ch),
_ => {}
}
}
let mut collapsed = String::with_capacity(out.len());
let mut prev_ws = false;
for ch in out.chars() {
if ch.is_whitespace() {
if !prev_ws {
collapsed.push(' ');
}
prev_ws = true;
} else {
collapsed.push(ch);
prev_ws = false;
}
}
collapsed.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokenize_latin_words() {
let t = tokenize("Hello World, build a site!");
assert!(t.iter().any(|s| s == "hello"));
assert!(t.iter().any(|s| s == "world"));
assert!(t.iter().any(|s| s == "build"));
assert!(t.iter().any(|s| s == "site"));
assert!(!t.iter().any(|s| s == "a")); }
#[test]
fn tokenize_cjk_bigrams() {
let t = tokenize("构建网站");
assert!(t.iter().any(|s| s == "构建"));
assert!(t.iter().any(|s| s == "建网"));
assert!(t.iter().any(|s| s == "网站"));
}
#[test]
fn shard_for_is_deterministic() {
let s = shard_for("lagrange");
assert!(s < SHARD_COUNT);
assert_eq!(shard_for("lagrange"), s);
}
#[test]
fn strip_html_removes_tags() {
assert_eq!(strip_html("<p>hello <b>world</b></p>"), "hello world");
assert_eq!(strip_html("no tags"), "no tags");
}
}