PostgreSQL search: find words inside URLs and file names in body text
ci / fork-checks (pull_request) Successful in 47s
ci / build (pull_request) Canceled after 4m49s

After #37, address fields on PostgreSQL are split into words as the
built-in index splits them, but language text (subject, body,
attachments) still goes straight to PostgreSQL's parser, which keeps a
URL, host, path or file name as tokens of its own:
"https://x.example/shipping-support/" becomes a url, a host and a
url_path, "invoice-2024.pdf" a file. So TEXT/BODY "shipping" missed
messages where the word appears only inside a link, while RocksDB and
the other built-in backends found them: 8 messages across a handful
of searches in the rehearsal.

On insert, language text is now indexed as it was, followed by the
word parts of each token that holds a URL separator (/ . @ : ? = & # _
% + ~ \), split with SpaceTokenizer as keyword_terms() splits addresses.
The parts go through the same text search configuration as the rest of
the text, so they are stemmed like the words around them. Plain words,
words that only carry punctuation ("end.", "(see") and hyphenated words
(the parser already splits those) add nothing, so text without links
is indexed exactly as before. Each part is added once per document.
On sample mail, the text vector of a short order notice with three
links grows from 546 to 716 bytes, a newsletter with 25 tracking links
from 5586 to 6430, and a plain letter not at all.

On search, a query word written as a URL, host, file or hyphenated word
also matches as its word parts, ORed with the query as written, so
"shipping-support" or "invoice-2024.pdf" match the new parts and
documents indexed before this change still match as they did.

Existing messages keep their old vectors until they are reindexed (the
reindexAccounts task); new and reindexed messages match at once.

store::search_tests gains test_url_word_search: five bodies, 19 body
searches for words found only in a URL path, query string, host or
file name, the tokens as written, plain words and non-matches, with the
same expected ids on every backend. It passes on RocksDB, SQLite,
MySQL and PostgreSQL; on main PostgreSQL fails at the first ("shipping"
finds [3], not [0, 3]). On PostgreSQL the suite then stops at the
account sort assertion (query.rs:689) exactly as it does on main.
This commit is contained in:
2026-09-24 21:33:17 -07:00
parent 51b159a1a2
commit 54eaf5d3d6
2 changed files with 178 additions and 1 deletions
+81
View File
@@ -133,6 +133,11 @@ pub async fn test(test: &TestServer) {
println!("Running address search tests...");
test_address_search(store.clone()).await;
// inbuxa: words inside URLs, host names and file names in body text
// are found on every backend
println!("Running URL word search tests...");
test_url_word_search(store.clone()).await;
// Large document insert test
println!("Running large document insert tests...");
let mut large_text = String::with_capacity(20 * 1024 * 1024);
@@ -1129,3 +1134,79 @@ async fn test_address_search(store: SearchStore) {
.await
.unwrap();
}
async fn test_url_word_search(store: SearchStore) {
const ACCOUNT_ID: u32 = 8;
let bodies = [
"Track your parcel here: https://x.example/shipping-support/ and reply.",
"Reset it at https://mail.example.com/login/?password=reset&user=jane now.",
"Attached is invoice-2024.pdf for your records.",
"Shipping was fast, thanks again.",
"Nothing to see at www.example.org/about-us, really.",
];
let mut documents = Vec::new();
let mut mask = RoaringBitmap::new();
for (document_id, body) in bodies.iter().enumerate() {
let mut document = IndexDocument::new(SearchIndex::Email)
.with_account_id(ACCOUNT_ID)
.with_document_id(document_id as u32);
document.index_text(EmailSearchField::Body, body, Language::English);
document.index_unsigned(EmailSearchField::ReceivedAt, document_id as u64);
documents.push(document);
mask.insert(document_id as u32);
}
store.index(documents).await.unwrap();
if let SearchStore::ElasticSearch(store) = &store {
store.refresh_index(SearchIndex::Email).await.unwrap();
}
for (text, expected) in [
// only inside a URL path, a query string or a file name
("shipping", vec![0u32, 3]),
("support", vec![0]),
("password", vec![1]),
("login", vec![1]),
("jane", vec![1]),
("invoice", vec![2]),
("pdf", vec![2]),
("2024", vec![2]),
// host names
("example", vec![0, 1, 4]),
("mail", vec![1]),
// written as they appear
("https://x.example/shipping-support/", vec![0]),
("shipping-support", vec![0]),
("invoice-2024.pdf", vec![2]),
("mail.example.com", vec![1]),
// plain words are unaffected
("parcel", vec![0]),
("records", vec![2]),
("thanks", vec![3]),
// no match
("billing", vec![]),
("example.net", vec![]),
] {
let ids = store
.query_account(
SearchQuery::new(SearchIndex::Email)
.with_filters(vec![
SearchFilter::eq(SearchField::AccountId, ACCOUNT_ID),
SearchFilter::has_english_text(EmailSearchField::Body, text),
])
.with_comparator(SearchComparator::ascending(EmailSearchField::ReceivedAt))
.with_mask(mask.clone()),
)
.await
.unwrap();
assert_eq!(ids, expected, "Body {text:?}");
}
store
.unindex(
SearchQuery::new(SearchIndex::Email)
.with_filter(SearchFilter::eq(SearchField::AccountId, ACCOUNT_ID)),
)
.await
.unwrap();
}