blob: b99de0328cf3db0bfbd27007c489d6aa83cd9be4 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
use crate::error::{FtIndexError, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TokenizerKind {
#[default]
Default,
Simple,
Whitespace,
Raw,
Ngram,
Jieba,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct TokenizerConfig {
pub tokenizer: TokenizerKind,
pub ngram_min_gram: usize,
pub ngram_max_gram: usize,
pub ngram_prefix_only: bool,
pub jieba_search_mode: bool,
pub jieba_ordinal_position: bool,
pub lower_case: bool,
pub max_token_length: usize,
pub ascii_folding: bool,
pub stem: bool,
pub language: String,
pub remove_stop_words: bool,
pub stop_words: Vec<String>,
pub with_position: bool,
}
impl Default for TokenizerConfig {
fn default() -> Self {
Self {
tokenizer: TokenizerKind::Default,
ngram_min_gram: 3,
ngram_max_gram: 3,
ngram_prefix_only: false,
jieba_search_mode: true,
jieba_ordinal_position: true,
lower_case: true,
max_token_length: 40,
ascii_folding: true,
stem: true,
language: "english".to_string(),
remove_stop_words: true,
stop_words: Vec::new(),
with_position: true,
}
}
}
impl TokenizerConfig {
pub fn from_options(options: &HashMap<String, String>) -> Result<Self> {
let mut config = Self::default();
for (raw_key, value) in options {
let key = raw_key
.strip_prefix("fulltext.")
.or_else(|| raw_key.strip_prefix("tantivy."))
.unwrap_or(raw_key.as_str());
let key = normalize_option_key(key);
match key.as_str() {
"tokenizer" => {
config.tokenizer = parse_tokenizer(value)?;
}
"ngram.min-gram" => {
config.ngram_min_gram = parse_usize(key, value)?;
}
"ngram.max-gram" => {
config.ngram_max_gram = parse_usize(key, value)?;
}
"ngram.prefix-only" => {
config.ngram_prefix_only = parse_bool(key, value)?;
}
"jieba.search-mode" => {
config.jieba_search_mode = parse_bool(key, value)?;
}
"jieba.ordinal-position" | "jieba.ordinal-position-mode" => {
config.jieba_ordinal_position = parse_bool(key, value)?;
}
"lower-case" => {
config.lower_case = parse_bool(key, value)?;
}
"max-token-length" => {
config.max_token_length = parse_usize(key, value)?;
}
"ascii-folding" => {
config.ascii_folding = parse_bool(key, value)?;
}
"stem" => {
config.stem = parse_bool(key, value)?;
}
"language" => {
config.language = value.trim().to_lowercase();
}
"remove-stop-words" => {
config.remove_stop_words = parse_bool(key, value)?;
}
"stop-words" => {
config.stop_words = value
.split(';')
.map(str::trim)
.filter(|word| !word.is_empty())
.map(ToOwned::to_owned)
.collect();
}
"with-position" => {
config.with_position = parse_bool(key, value)?;
}
_ => {}
}
}
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<()> {
if self.ngram_min_gram == 0 {
return invalid("ngram.min-gram", "must be positive");
}
if self.ngram_max_gram == 0 {
return invalid("ngram.max-gram", "must be positive");
}
if self.ngram_min_gram > self.ngram_max_gram {
return invalid("ngram.min-gram", "must not exceed ngram.max-gram");
}
if self.max_token_length == 0 {
return invalid("max-token-length", "must be positive");
}
if !self.remove_stop_words && !self.stop_words.is_empty() {
return invalid("stop-words", "requires remove-stop-words=true");
}
Ok(())
}
}
fn parse_tokenizer(value: &str) -> Result<TokenizerKind> {
let value = value.trim().to_lowercase();
match value.as_str() {
"default" => Ok(TokenizerKind::Default),
"simple" => Ok(TokenizerKind::Simple),
"whitespace" => Ok(TokenizerKind::Whitespace),
"raw" => Ok(TokenizerKind::Raw),
"ngram" => Ok(TokenizerKind::Ngram),
"jieba" => Ok(TokenizerKind::Jieba),
other => Err(FtIndexError::InvalidOption {
key: "tokenizer".to_string(),
message: format!("unsupported tokenizer '{other}'"),
}),
}
}
fn normalize_option_key(key: &str) -> String {
key.trim().to_lowercase()
}
fn parse_usize(key: impl Into<String>, value: &str) -> Result<usize> {
let key = key.into();
value
.trim()
.parse()
.map_err(|_| FtIndexError::InvalidOption {
key,
message: format!("expected unsigned integer, got '{value}'"),
})
}
fn parse_bool(key: impl Into<String>, value: &str) -> Result<bool> {
let key = key.into();
match value.trim().to_lowercase().as_str() {
"true" => Ok(true),
"false" => Ok(false),
_ => Err(FtIndexError::InvalidOption {
key,
message: format!("expected boolean, got '{value}'"),
}),
}
}
fn invalid<T>(key: &str, message: &str) -> Result<T> {
Err(FtIndexError::InvalidOption {
key: key.to_string(),
message: message.to_string(),
})
}