From 42239f91b3ad4ae2054aefac88ca473cd159d628 Mon Sep 17 00:00:00 2001 From: chuan Date: Tue, 11 Aug 2026 12:50:08 +0800 Subject: [PATCH] feat(search): remove regex query mode --- README.md | 5 +-- TODOS.md | 2 +- src/search/README.md | 3 +- src/search/src/api/handlers.rs | 25 ++++++------ src/search/src/api/mod.rs | 18 +-------- src/search/src/api/request.rs | 8 ++-- src/search/src/bin/dht-benchmark/workload.rs | 9 ++--- src/search/src/search/file_order.rs | 37 +++++------------- src/search/src/search/filter.rs | 37 +++--------------- src/search/src/search/indexer.rs | 41 -------------------- src/search/src/search/mod.rs | 4 +- src/search/src/search/query.rs | 9 ----- src/web/README.md | 2 +- src/web/src/lib/api.ts | 4 -- src/web/src/lib/search-mode.ts | 17 -------- src/web/src/pages/SearchPage.vue | 4 +- 16 files changed, 43 insertions(+), 182 deletions(-) delete mode 100644 src/web/src/lib/search-mode.ts diff --git a/README.md b/README.md index c987f7c..ae37bf0 100644 --- a/README.md +++ b/README.md @@ -141,15 +141,14 @@ bun run build | 英文关键词 | 7.746 ms | | 文件路径片段 | 1.063 ms | | 精确 infohash | 0.031 ms | -| 有限状态正则 | 675.803 ms | | 最近收录排序 | 3.367 ms | | 大小扩展名过滤 | 6.641 ms | -百万级基准中普通搜索过滤排序精确哈希索引吞吐磁盘和峰值内存均达到当前目标 大命中集合正则仍是继续扩大规模前最值得优化的查询路径 +百万级基准中普通搜索过滤排序精确哈希索引吞吐磁盘和峰值内存均达到当前目标 大型种子会先按文件大小降序和规范化路径稳定排序 最多索引 2048 个文件且完整路径文本总量不超过 256 KiB 以优先覆盖主体内容并限制极端 Metadata 的索引放大 -搜索索引对标题使用最多 10 字符的有限 N-Gram 对文件名使用最多 8 字符的有限 N-Gram 完整路径仅按目录段和单词分词 普通文本查询会按各字段策略拆分 正则和通配符继续使用完整规范化文本字段 索引不保存未使用的词位置信息并在首次写入前延迟创建 Tantivy writer +搜索索引对标题使用最多 10 字符的有限 N-Gram 对文件名使用最多 8 字符的有限 N-Gram 完整路径仅按目录段和单词分词 普通文本查询会按各字段策略拆分 通配符使用完整规范化文本字段 索引不保存未使用的词位置信息并在首次写入前延迟创建 Tantivy writer 每条记录包含 2048 个文件的极端基准中 450 个内容文档的 Tantivy 索引为 19.84 MiB 平均每文档 46.2 KiB 峰值内存为 113.08 MiB diff --git a/TODOS.md b/TODOS.md index b3e2014..932d7a8 100644 --- a/TODOS.md +++ b/TODOS.md @@ -30,7 +30,7 @@ ### 验收标准 - [ ] 使用远端真实数据或等比例数据对比优化前后的索引体积 -- [ ] 普通文本通配符正则扩展名精确哈希和详情文件匹配测试保持通过 +- [ ] 普通文本通配符扩展名精确哈希和详情文件匹配测试保持通过 - [ ] 影子索引重建期间旧索引继续提供搜索且完成后能够原子切换 - [ ] 重建峰值空间不会触发磁盘只读保护 diff --git a/src/search/README.md b/src/search/README.md index a5196e5..95b22e7 100644 --- a/src/search/README.md +++ b/src/search/README.md @@ -255,7 +255,6 @@ GET /search?q=ubuntu&offset=0&limit=20 GET /search?q=%2A.iso GET /search?q=&min_size=1048576&max_size=10737418240&extension=mkv GET /search?q=流浪地球&min_files=1&availability=active&heat=hot&sort=heat -GET /search?q=%5ES%5Cd%7B2%7DE%5Cd%7B2%7D&mode=regex GET /contents/{content_key}?offset=0&limit=20 GET /torrents/{infohash}?file_offset=0&file_limit=100 ``` @@ -266,7 +265,7 @@ GET /torrents/{infohash}?file_offset=0&file_limit=100 默认搜索会自动识别不区分大小写的通配符并匹配名称 别名和文件路径 `*` 表示任意长度字符 `?` 表示一个字符 例如 `*.iso` 匹配所有以 `.iso` 结尾的已索引名称或文件路径 不含通配符时保持普通关键词和片段搜索 -设置 `mode=regex` 后查询文本作为不区分大小写的正则表达式匹配名称 别名和文件路径 通配符和正则最长 256 字节并由 Tantivy 有限状态自动机执行 +搜索接口不接受正则模式 `mode` 参数 通配符最长 256 字节 过滤参数还包括 `min_files` `max_files` `first_seen_after` `first_seen_before` `last_seen_after` `last_seen_before` `availability` 和 `heat` diff --git a/src/search/src/api/handlers.rs b/src/search/src/api/handlers.rs index 78d8f76..4e62774 100644 --- a/src/search/src/api/handlers.rs +++ b/src/search/src/api/handlers.rs @@ -7,7 +7,7 @@ use std::{ use crate::{ domain::{InfoHash, MetadataRejectionReason}, - search::{SearchMode, SearchOptions, SearchPage, SearchSort}, + search::{SearchOptions, SearchPage, SearchSort}, storage::VerificationPriority, }; use axum::{ @@ -271,10 +271,11 @@ pub(crate) async fn search( if request.q.len() > 512 { return Err(ApiError::bad_request("查询文本不能超过 512 字节")); } - if (request.mode != SearchMode::Text || request.q.contains('*') || request.q.contains('?')) - && request.q.len() > 256 - { - return Err(ApiError::bad_request("通配符或正则表达式不能超过 256 字节")); + if request.mode.is_some() { + return Err(ApiError::bad_request("搜索不再支持 mode 参数和正则表达式")); + } + if (request.q.contains('*') || request.q.contains('?')) && request.q.len() > 256 { + return Err(ApiError::bad_request("通配符不能超过 256 字节")); } validate_range(request.min_size, request.max_size, "min_size", "max_size")?; validate_range( @@ -296,11 +297,7 @@ pub(crate) async fn search( "last_seen_before", )?; let mut query = request.q; - let content_key = if request.mode == SearchMode::Text { - InfoHash::from_str(query.trim()).ok() - } else { - None - }; + let content_key = InfoHash::from_str(query.trim()).ok(); let content_key = if let Some(info_hash) = content_key { let repository = state.repository.clone(); let record = tokio::task::spawn_blocking(move || repository.get_visible(info_hash)) @@ -325,7 +322,6 @@ pub(crate) async fn search( let mut page = tokio::task::spawn_blocking(move || { search.search_with(SearchOptions { query, - mode: request.mode, offset: request.offset, limit: request.limit, min_size: request.min_size, @@ -435,6 +431,11 @@ pub(crate) async fn torrent( if request.file_offset > 1_000_000 { return Err(ApiError::bad_request("file_offset 不能超过 1000000")); } + if request.mode.is_some() { + return Err(ApiError::bad_request( + "详情接口不再支持 mode 参数和正则表达式", + )); + } let verification = state.crawler.verification(); let info_hash = InfoHash::from_str(&info_hash).map_err(|error| ApiError::bad_request(error.to_string()))?; @@ -444,7 +445,7 @@ pub(crate) async fn torrent( .map_err(|error| ApiError::internal(error.to_string()))? .ok_or_else(|| ApiError::not_found("没有找到该 infohash"))?; let mut record = record; - record.files = crate::search::order_files(record.files, &request.q, request.mode, request.sort) + record.files = crate::search::order_files(record.files, &request.q, request.sort) .map_err(|error| ApiError::bad_request(error.to_string()))?; if let Some(verification) = &verification { verification diff --git a/src/search/src/api/mod.rs b/src/search/src/api/mod.rs index f6b285a..913cb90 100644 --- a/src/search/src/api/mod.rs +++ b/src/search/src/api/mod.rs @@ -393,11 +393,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let json: serde_json::Value = - serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) - .unwrap(); - assert_eq!(json["total"], 1); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let response = app .clone() @@ -415,18 +411,6 @@ mod tests { .unwrap(); assert_eq!(json["total"], 1); - let response = app - .clone() - .oneshot( - Request::builder() - .uri("/search?q=%5B&mode=regex") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let response = app .clone() .oneshot( diff --git a/src/search/src/api/request.rs b/src/search/src/api/request.rs index f9a67a6..08d9707 100644 --- a/src/search/src/api/request.rs +++ b/src/search/src/api/request.rs @@ -2,7 +2,7 @@ use crate::{ domain::{AvailabilityStatus, HeatLevel}, - search::{SearchMode, SearchSort}, + search::SearchSort, }; use serde::Deserialize; @@ -19,8 +19,7 @@ fn default_limit() -> usize { pub(crate) struct SearchRequest { #[serde(default)] pub(crate) q: String, - #[serde(default)] - pub(crate) mode: SearchMode, + pub(crate) mode: Option, #[serde(default)] pub(crate) offset: usize, #[serde(default = "default_limit")] @@ -55,8 +54,7 @@ pub(crate) struct TorrentRequest { pub(crate) file_limit: usize, #[serde(default)] pub(crate) q: String, - #[serde(default)] - pub(crate) mode: SearchMode, + pub(crate) mode: Option, pub(crate) sort: Option, } diff --git a/src/search/src/bin/dht-benchmark/workload.rs b/src/search/src/bin/dht-benchmark/workload.rs index 8742230..d9c5476 100644 --- a/src/search/src/bin/dht-benchmark/workload.rs +++ b/src/search/src/bin/dht-benchmark/workload.rs @@ -1,8 +1,8 @@ -// 负责定义覆盖关键词路径哈希正则排序和过滤的固定查询工作负载 +// 负责定义覆盖关键词路径哈希通配符排序和过滤的固定查询工作负载 use std::error::Error; -use dht_search::search::{SearchMode, SearchOptions, SearchSort}; +use dht_search::search::{SearchOptions, SearchSort}; use super::dataset::generate_record; @@ -53,10 +53,9 @@ pub(crate) fn query_cases( }, }, QueryCase { - name: "有限状态正则", + name: "文件通配符", options: SearchOptions { - query: "ubuntu.*desktop".into(), - mode: SearchMode::Regex, + query: "ubuntu*desktop".into(), limit: 20, ..SearchOptions::default() }, diff --git a/src/search/src/search/file_order.rs b/src/search/src/search/file_order.rs index 6727e71..a4ca85b 100644 --- a/src/search/src/search/file_order.rs +++ b/src/search/src/search/file_order.rs @@ -7,21 +7,20 @@ use unicode_normalization::UnicodeNormalization; use crate::domain::TorrentFile; -use super::{SearchMode, SearchSort}; +use super::SearchSort; #[derive(Debug, thiserror::Error)] pub(crate) enum FileOrderError { - #[error("文件相关性正则表达式无效: {0}")] - InvalidRegex(#[from] regex::Error), + #[error("文件相关性通配符无效: {0}")] + InvalidWildcard(#[from] regex::Error), } pub(crate) fn order_files( files: Vec, query: &str, - mode: SearchMode, sort: Option, ) -> Result, FileOrderError> { - let matcher = FileMatcher::compile(query, mode)?; + let matcher = FileMatcher::compile(query)?; let secondary = SecondaryOrder::from_search_sort(sort); let mut ranked: Vec<_> = files .into_iter() @@ -73,19 +72,11 @@ enum FileMatcher { } impl FileMatcher { - fn compile(query: &str, mode: SearchMode) -> Result { + fn compile(query: &str) -> Result { let query = query.trim(); if query.is_empty() || query == "*" { return Ok(Self::None); } - if mode == SearchMode::Regex { - return RegexBuilder::new(&query.to_lowercase()) - .case_insensitive(true) - .unicode(true) - .build() - .map(Self::Pattern) - .map_err(FileOrderError::from); - } if query.contains('*') || query.contains('?') { return RegexBuilder::new(&wildcard_regex(&normalize(query))) .unicode(true) @@ -179,7 +170,7 @@ mod tests { file("ubuntu/docs/a.txt", 2), file("a/ubuntu.iso", 3), ]; - let ordered = order_files(files, "ubuntu", SearchMode::Text, None).unwrap(); + let ordered = order_files(files, "ubuntu", None).unwrap(); assert_eq!(ordered[0].path, "a/ubuntu.iso"); assert_eq!(ordered[1].path, "ubuntu/docs/a.txt"); assert_eq!(ordered[2].path, "z/readme.txt"); @@ -193,8 +184,7 @@ mod tests { file("match-large.iso", 10), file("unrelated-small.bin", 2), ]; - let ordered = - order_files(files, "match", SearchMode::Text, Some(SearchSort::SizeDesc)).unwrap(); + let ordered = order_files(files, "match", Some(SearchSort::SizeDesc)).unwrap(); assert_eq!(ordered[0].path, "match-large.iso"); assert_eq!(ordered[1].path, "match-small.iso"); assert_eq!(ordered[2].path, "unrelated-large.bin"); @@ -202,18 +192,9 @@ mod tests { } #[test] - fn wildcard_and_regex_follow_search_mode() { + fn wildcard_promotes_matching_files() { let files = vec![file("a/movie.mkv", 1), file("b/movie.iso", 2)]; - let wildcard = order_files( - files.clone(), - "*.iso", - SearchMode::Text, - Some(SearchSort::SizeAsc), - ) - .unwrap(); + let wildcard = order_files(files, "*.iso", Some(SearchSort::SizeAsc)).unwrap(); assert_eq!(wildcard[0].path, "b/movie.iso"); - - let regex = order_files(files, r"movie\.mkv$", SearchMode::Regex, None).unwrap(); - assert_eq!(regex[0].path, "a/movie.mkv"); } } diff --git a/src/search/src/search/filter.rs b/src/search/src/search/filter.rs index f721f8b..971f794 100644 --- a/src/search/src/search/filter.rs +++ b/src/search/src/search/filter.rs @@ -12,7 +12,7 @@ use unicode_normalization::UnicodeNormalization; use super::{ SearchError, document::availability_number, - query::{SearchMode, SearchOptions, SearchSort}, + query::{SearchOptions, SearchSort}, schema::SearchFields, }; @@ -30,12 +30,10 @@ pub(crate) fn prepare( if query_text.is_empty() || query_text == "*" { clauses.push(Box::new(AllQuery)); } else { - clauses.push(match options.mode { - SearchMode::Text if has_wildcard_syntax(query_text) => { - wildcard_query(query_text, fields)? - } - SearchMode::Text => text_query(query_text, fields)?, - SearchMode::Regex => regex_query(query_text, fields)?, + clauses.push(if has_wildcard_syntax(query_text) { + wildcard_query(query_text, fields)? + } else { + text_query(query_text, fields)? }); } if let Some(content_key) = options.content_key { @@ -110,31 +108,6 @@ pub(crate) fn prepare( Ok(PreparedQuery { query, sort }) } -fn regex_query(pattern: &str, fields: SearchFields) -> Result, SearchError> { - let mut pattern = pattern.to_lowercase(); - let anchored_start = pattern.starts_with('^'); - if anchored_start { - pattern.remove(0); - } - let anchored_end = pattern.ends_with('$') - && pattern[..pattern.len() - 1] - .chars() - .rev() - .take_while(|character| *character == '\\') - .count() - .is_multiple_of(2); - if anchored_end { - pattern.pop(); - } - let prefix = if anchored_start { "" } else { ".*" }; - let suffix = if anchored_end { "" } else { ".*" }; - let contains_pattern = format!("{prefix}({pattern}){suffix}"); - Ok(Box::new(RegexQuery::from_pattern( - &contains_pattern, - fields.regex_text, - )?)) -} - fn wildcard_query(pattern: &str, fields: SearchFields) -> Result, SearchError> { Ok(Box::new(RegexQuery::from_pattern( &wildcard_pattern(pattern), diff --git a/src/search/src/search/indexer.rs b/src/search/src/search/indexer.rs index c3b5d78..2ef26f8 100644 --- a/src/search/src/search/indexer.rs +++ b/src/search/src/search/indexer.rs @@ -461,7 +461,6 @@ mod tests { use crate::domain::{ AvailabilityStatus, ContentGroupBuilder, InfoHash, TorrentFile, TorrentRecord, }; - use crate::search::SearchMode; use crate::storage::{RocksTorrentRepository, TorrentRepository}; use super::*; @@ -674,32 +673,6 @@ mod tests { assert_eq!(engine.search("1080p", 0, 10).unwrap().total, 1); } - #[test] - fn regex_matches_names_and_file_paths_case_insensitively() { - let directory = TempDir::new().unwrap(); - let engine = SearchEngine::open(directory.path()).unwrap(); - index_records(&engine, &[record()]); - - let name = engine - .search_with(SearchOptions { - query: r"ubuntu\s+linux\s+24\.0[0-9]".into(), - mode: SearchMode::Regex, - limit: 10, - ..SearchOptions::default() - }) - .unwrap(); - assert_eq!(name.total, 1); - let path = engine - .search_with(SearchOptions { - query: r"ubuntu\.(iso|img)$".into(), - mode: SearchMode::Regex, - limit: 10, - ..SearchOptions::default() - }) - .unwrap(); - assert_eq!(path.total, 1); - } - #[test] fn wildcard_matches_names_and_file_extensions() { let directory = TempDir::new().unwrap(); @@ -726,20 +699,6 @@ mod tests { assert_eq!(missing.total, 0); } - #[test] - fn invalid_regex_is_rejected() { - let directory = TempDir::new().unwrap(); - let engine = SearchEngine::open(directory.path()).unwrap(); - index_records(&engine, &[record()]); - let result = engine.search_with(SearchOptions { - query: "[".into(), - mode: SearchMode::Regex, - limit: 10, - ..SearchOptions::default() - }); - assert!(result.is_err()); - } - #[test] fn equal_content_is_collapsed_and_aliases_remain_searchable() { let directory = TempDir::new().unwrap(); diff --git a/src/search/src/search/mod.rs b/src/search/src/search/mod.rs index 99a0001..075b008 100644 --- a/src/search/src/search/mod.rs +++ b/src/search/src/search/mod.rs @@ -10,9 +10,7 @@ mod schema; pub(crate) use file_order::order_files; pub use indexer::{SearchDiagnostics, SearchEngine}; -pub use query::{ - AvailabilitySummary, SearchHit, SearchMode, SearchOptions, SearchPage, SearchSort, -}; +pub use query::{AvailabilitySummary, SearchHit, SearchOptions, SearchPage, SearchSort}; pub use runtime::{IndexRebuildReason, IndexState, IndexStatus, SearchRuntime}; pub(crate) use runtime::{RebuildSession, SearchBootstrap}; diff --git a/src/search/src/search/query.rs b/src/search/src/search/query.rs index b4e00a5..dac6822 100644 --- a/src/search/src/search/query.rs +++ b/src/search/src/search/query.rs @@ -17,18 +17,9 @@ pub enum SearchSort { Discoveries, } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum SearchMode { - #[default] - Text, - Regex, -} - #[derive(Debug, Clone, Default)] pub struct SearchOptions { pub query: String, - pub mode: SearchMode, pub offset: usize, pub limit: usize, pub min_size: Option, diff --git a/src/web/README.md b/src/web/README.md index 4accc07..4496250 100644 --- a/src/web/README.md +++ b/src/web/README.md @@ -50,7 +50,7 @@ Axum 根据配置中的 `http.web_dir` 提供静态资源和单页回退 不需 ## 已实现功能 - 关键词 文件名片段和精确 infohash 搜索 -- 根据输入语法自动识别普通关键词 通配符和名称文件路径正则搜索 +- 支持普通关键词 通配符和精确 infohash 搜索 - 相关度 时间 热度 大小和发现次数排序 - 有上限的结果分页和 URL 查询恢复 - 基于 reka-ui 的搜索结果数字分页和浏览器持久化每页数量选择 diff --git a/src/web/src/lib/api.ts b/src/web/src/lib/api.ts index 25e14cc..c497e10 100644 --- a/src/web/src/lib/api.ts +++ b/src/web/src/lib/api.ts @@ -10,8 +10,6 @@ import type { ServiceStats, TorrentDetail, } from '@/types/api' -import { isLikelyRegex } from '@/lib/search-mode' - interface SearchInput { query: string offset: number @@ -45,13 +43,11 @@ export function search(input: SearchInput, signal?: AbortSignal): Promise(`/search?${params}`, signal) } export function getTorrent(infoHash: string, fileOffset = 0, fileLimit = 100, query = '', sort: SearchSort = 'latest', signal?: AbortSignal): Promise { const params = new URLSearchParams({ file_offset: String(fileOffset), file_limit: String(fileLimit), q: query.trim(), sort }) - if (isLikelyRegex(query)) params.set('mode', 'regex') return request(`/torrents/${encodeURIComponent(infoHash)}?${params}`, signal) } diff --git a/src/web/src/lib/search-mode.ts b/src/web/src/lib/search-mode.ts deleted file mode 100644 index ad765e5..0000000 --- a/src/web/src/lib/search-mode.ts +++ /dev/null @@ -1,17 +0,0 @@ -// 负责根据查询语法判断是否使用正则搜索 - -const REGEX_SIGNALS = [ - /^\^/, - /(^|[^\\])\$$/, - /\\[AbBdDsSwWZz]/, - /(^|[^\\])\[[^\]]+\]/, - /(^|[^\\])\([^)]*\)/, - /(^|[^\\])\|/, - /(^|[^\\])\{(?:\d+|\d+,\d*|,\d+)\}/, - /\.\*|\.\+/, -] - -export function isLikelyRegex(query: string): boolean { - const value = query.trim() - return value !== '' && REGEX_SIGNALS.some((signal) => signal.test(value)) -} diff --git a/src/web/src/pages/SearchPage.vue b/src/web/src/pages/SearchPage.vue index 8714c87..fbc1772 100644 --- a/src/web/src/pages/SearchPage.vue +++ b/src/web/src/pages/SearchPage.vue @@ -42,7 +42,7 @@ let indexTimer: number | null = null const pageNumber = computed(() => Math.floor((page.value?.offset ?? 0) / limit.value) + 1) const paginationTotal = computed(() => Math.min(page.value?.total ?? 0, 10_000 + limit.value)) -const searchPlaceholder = '输入名称、文件名、*.iso、正则表达式或 40 位 infohash' +const searchPlaceholder = '输入名称、文件名、*.iso 或 40 位 infohash' const resultPageSizeOptions = [ { value: '10', label: '10 条/页' }, { value: '20', label: '20 条/页' }, @@ -237,7 +237,7 @@ onBeforeUnmount(() => {
正在搜索

无法完成搜索

{{ error }}

-

没有找到匹配内容

尝试其他关键词、通配符或正则表达式

+

没有找到匹配内容

尝试其他关键词或通配符