Switchable tokenizer (#776)
* [REFACTORING]Rename whitespace_tokenizer to tag_tokenizer for registration Name representing its purpose is preferred. * Add lindera-tantivy to plume-model's dependencies * Install lindera-tantivy * Add SearchTokenizerConfig struct * Add search tokenizers to config option * Use CONFIG for tokenizers * Use enum to hold tokenizer config instead of initializing on config phase * Use guard instead of duplicate default values * Use as_deref() instead of guard * Move SearchTokenizer from plume-models to plume-models::search::tokenizer * Rename SearchTokenizer to TokenizerKind * Define SearchTokenierConfig::determine_tokenizer() * Use determine_tokenizer in SearchTokenizerConfig::init() * Pass tokenizer config to Searcher methods * Add LowerCase filter to Lindera tokenizer * Add test for Lindera tokenizer * Define SEARCH_LANG env to specify tokenizers set * Run cargo fmt * Make Lindera tokenizer optional * Fix typos
This commit is contained in:
@@ -30,6 +30,7 @@ whatlang = "0.7.1"
|
||||
shrinkwraprs = "0.2.1"
|
||||
diesel-derive-newtype = "0.1.2"
|
||||
glob = "0.3.0"
|
||||
lindera-tantivy = { version = "0.1.2", optional = true }
|
||||
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
@@ -54,3 +55,4 @@ diesel_migrations = "1.3.0"
|
||||
[features]
|
||||
postgres = ["diesel/postgres", "plume-macro/postgres" ]
|
||||
sqlite = ["diesel/sqlite", "plume-macro/sqlite" ]
|
||||
search-lindera = ["lindera-tantivy"]
|
||||
|
||||
@@ -498,6 +498,7 @@ pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
blog_authors::*,
|
||||
config::CONFIG,
|
||||
instance::tests as instance_tests,
|
||||
medias::NewMedia,
|
||||
search::tests::get_searcher,
|
||||
@@ -767,7 +768,9 @@ pub(crate) mod tests {
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (_, blogs) = fill_database(conn);
|
||||
|
||||
blogs[0].delete(conn, &get_searcher()).unwrap();
|
||||
blogs[0]
|
||||
.delete(conn, &get_searcher(&CONFIG.search_tokenizers))
|
||||
.unwrap();
|
||||
assert!(Blog::get(conn, blogs[0].id).is_err());
|
||||
Ok(())
|
||||
})
|
||||
@@ -777,7 +780,7 @@ pub(crate) mod tests {
|
||||
fn delete_via_user() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let searcher = get_searcher();
|
||||
let searcher = get_searcher(&CONFIG.search_tokenizers);
|
||||
let (user, _) = fill_database(conn);
|
||||
|
||||
let b1 = Blog::insert(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::search::TokenizerKind as SearchTokenizer;
|
||||
use rocket::config::Limits;
|
||||
use rocket::Config as RocketConfig;
|
||||
use std::env::{self, var};
|
||||
@@ -14,6 +15,7 @@ pub struct Config {
|
||||
pub db_max_size: Option<u32>,
|
||||
pub db_min_idle: Option<u32>,
|
||||
pub search_index: String,
|
||||
pub search_tokenizers: SearchTokenizerConfig,
|
||||
pub rocket: Result<RocketConfig, RocketError>,
|
||||
pub logo: LogoConfig,
|
||||
pub default_theme: String,
|
||||
@@ -188,6 +190,56 @@ impl Default for LogoConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SearchTokenizerConfig {
|
||||
pub tag_tokenizer: SearchTokenizer,
|
||||
pub content_tokenizer: SearchTokenizer,
|
||||
pub property_tokenizer: SearchTokenizer,
|
||||
}
|
||||
|
||||
impl SearchTokenizerConfig {
|
||||
pub fn init() -> Self {
|
||||
use SearchTokenizer::*;
|
||||
|
||||
match var("SEARCH_LANG").ok().as_deref() {
|
||||
Some("ja") => {
|
||||
#[cfg(not(feature = "search-lindera"))]
|
||||
panic!("You need build Plume with search-lindera feature, or execute it with SEARCH_TAG_TOKENIZER=ngram and SEARCH_CONTENT_TOKENIZER=ngram to enable Japanese search feature");
|
||||
#[cfg(feature = "search-lindera")]
|
||||
Self {
|
||||
tag_tokenizer: Self::determine_tokenizer("SEARCH_TAG_TOKENIZER", Lindera),
|
||||
content_tokenizer: Self::determine_tokenizer(
|
||||
"SEARCH_CONTENT_TOKENIZER",
|
||||
Lindera,
|
||||
),
|
||||
property_tokenizer: Ngram,
|
||||
}
|
||||
}
|
||||
_ => Self {
|
||||
tag_tokenizer: Self::determine_tokenizer("SEARCH_TAG_TOKENIZER", Whitespace),
|
||||
content_tokenizer: Self::determine_tokenizer("SEARCH_CONTENT_TOKENIZER", Simple),
|
||||
property_tokenizer: Ngram,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn determine_tokenizer(var_name: &str, default: SearchTokenizer) -> SearchTokenizer {
|
||||
use SearchTokenizer::*;
|
||||
|
||||
match var(var_name).ok().as_deref() {
|
||||
Some("simple") => Simple,
|
||||
Some("ngram") => Ngram,
|
||||
Some("whitespace") => Whitespace,
|
||||
Some("lindera") => {
|
||||
#[cfg(not(feature = "search-lindera"))]
|
||||
panic!("You need build Plume with search-lindera feature to use Lindera tokenizer");
|
||||
#[cfg(feature = "search-lindera")]
|
||||
Lindera
|
||||
}
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
pub static ref CONFIG: Config = Config {
|
||||
base_url: var("BASE_URL").unwrap_or_else(|_| format!(
|
||||
@@ -209,6 +261,7 @@ lazy_static! {
|
||||
#[cfg(feature = "sqlite")]
|
||||
database_url: var("DATABASE_URL").unwrap_or_else(|_| format!("{}.sqlite", DB_NAME)),
|
||||
search_index: var("SEARCH_INDEX").unwrap_or_else(|_| "search_index".to_owned()),
|
||||
search_tokenizers: SearchTokenizerConfig::init(),
|
||||
rocket: get_rocket_config(),
|
||||
logo: LogoConfig::default(),
|
||||
default_theme: var("DEFAULT_THEME").unwrap_or_else(|_| "default-light".to_owned()),
|
||||
|
||||
@@ -320,7 +320,7 @@ mod tests {
|
||||
pub fn rockets() -> super::PlumeRocket {
|
||||
super::PlumeRocket {
|
||||
conn: db_conn::DbConn((*DB_POOL).get().unwrap()),
|
||||
searcher: Arc::new(search::tests::get_searcher()),
|
||||
searcher: Arc::new(search::tests::get_searcher(&CONFIG.search_tokenizers)),
|
||||
worker: Arc::new(ScheduledThreadPool::new(2)),
|
||||
user: None,
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ mod searcher;
|
||||
mod tokenizer;
|
||||
pub use self::query::PlumeQuery as Query;
|
||||
pub use self::searcher::*;
|
||||
pub use self::tokenizer::TokenizerKind;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::{Query, Searcher};
|
||||
use super::{Query, Searcher, TokenizerKind};
|
||||
use diesel::Connection;
|
||||
use plume_common::utils::random_hex;
|
||||
use std::env::temp_dir;
|
||||
@@ -14,18 +15,20 @@ pub(crate) mod tests {
|
||||
|
||||
use crate::{
|
||||
blogs::tests::fill_database,
|
||||
config::SearchTokenizerConfig,
|
||||
post_authors::*,
|
||||
posts::{NewPost, Post},
|
||||
safe_string::SafeString,
|
||||
tests::db,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
pub(crate) fn get_searcher() -> Searcher {
|
||||
pub(crate) fn get_searcher(tokenizers: &SearchTokenizerConfig) -> Searcher {
|
||||
let dir = temp_dir().join(&format!("plume-test-{}", random_hex()));
|
||||
if dir.exists() {
|
||||
Searcher::open(&dir)
|
||||
Searcher::open(&dir, tokenizers)
|
||||
} else {
|
||||
Searcher::create(&dir)
|
||||
Searcher::create(&dir, tokenizers)
|
||||
}
|
||||
.unwrap()
|
||||
}
|
||||
@@ -100,27 +103,27 @@ pub(crate) mod tests {
|
||||
fn open() {
|
||||
let dir = temp_dir().join(format!("plume-test-{}", random_hex()));
|
||||
{
|
||||
Searcher::create(&dir).unwrap();
|
||||
Searcher::create(&dir, &CONFIG.search_tokenizers).unwrap();
|
||||
}
|
||||
Searcher::open(&dir).unwrap();
|
||||
Searcher::open(&dir, &CONFIG.search_tokenizers).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create() {
|
||||
let dir = temp_dir().join(format!("plume-test-{}", random_hex()));
|
||||
|
||||
assert!(Searcher::open(&dir).is_err());
|
||||
assert!(Searcher::open(&dir, &CONFIG.search_tokenizers).is_err());
|
||||
{
|
||||
Searcher::create(&dir).unwrap();
|
||||
Searcher::create(&dir, &CONFIG.search_tokenizers).unwrap();
|
||||
}
|
||||
Searcher::open(&dir).unwrap(); //verify it's well created
|
||||
Searcher::open(&dir, &CONFIG.search_tokenizers).unwrap(); //verify it's well created
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let searcher = get_searcher();
|
||||
let searcher = get_searcher(&CONFIG.search_tokenizers);
|
||||
let blog = &fill_database(conn).1[0];
|
||||
let author = &blog.list_authors(conn).unwrap()[0];
|
||||
|
||||
@@ -180,10 +183,69 @@ pub(crate) mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "search-lindera")]
|
||||
#[test]
|
||||
fn search_japanese() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let tokenizers = SearchTokenizerConfig {
|
||||
tag_tokenizer: TokenizerKind::Lindera,
|
||||
content_tokenizer: TokenizerKind::Lindera,
|
||||
property_tokenizer: TokenizerKind::Ngram,
|
||||
};
|
||||
let searcher = get_searcher(&tokenizers);
|
||||
let blog = &fill_database(conn).1[0];
|
||||
|
||||
let title = random_hex()[..8].to_owned();
|
||||
|
||||
let post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blog.id,
|
||||
slug: title.clone(),
|
||||
title: title.clone(),
|
||||
content: SafeString::new("ブログエンジンPlumeです。"),
|
||||
published: true,
|
||||
license: "CC-BY-SA".to_owned(),
|
||||
ap_url: "".to_owned(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_owned(),
|
||||
source: "".to_owned(),
|
||||
cover_id: None,
|
||||
},
|
||||
&searcher,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
searcher.commit();
|
||||
|
||||
assert_eq!(
|
||||
searcher.search_document(conn, Query::from_str("ブログエンジン").unwrap(), (0, 1))
|
||||
[0]
|
||||
.id,
|
||||
post.id
|
||||
);
|
||||
assert_eq!(
|
||||
searcher.search_document(conn, Query::from_str("Plume").unwrap(), (0, 1))[0].id,
|
||||
post.id
|
||||
);
|
||||
assert_eq!(
|
||||
searcher.search_document(conn, Query::from_str("です").unwrap(), (0, 1))[0].id,
|
||||
post.id
|
||||
);
|
||||
assert_eq!(
|
||||
searcher.search_document(conn, Query::from_str("。").unwrap(), (0, 1))[0].id,
|
||||
post.id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_writer() {
|
||||
let searcher = get_searcher();
|
||||
let searcher = get_searcher(&CONFIG.search_tokenizers);
|
||||
searcher.drop_writer();
|
||||
get_searcher();
|
||||
get_searcher(&CONFIG.search_tokenizers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
use crate::{
|
||||
instance::Instance,
|
||||
posts::Post,
|
||||
schema::posts,
|
||||
search::{query::PlumeQuery, tokenizer},
|
||||
tags::Tag,
|
||||
Connection, Result,
|
||||
config::SearchTokenizerConfig, instance::Instance, posts::Post, schema::posts,
|
||||
search::query::PlumeQuery, tags::Tag, Connection, Result,
|
||||
};
|
||||
use chrono::Datelike;
|
||||
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
use itertools::Itertools;
|
||||
use std::{cmp, fs::create_dir_all, path::Path, sync::Mutex};
|
||||
use tantivy::{
|
||||
collector::TopDocs, directory::MmapDirectory, schema::*, tokenizer::*, Index, IndexReader,
|
||||
IndexWriter, ReloadPolicy, Term,
|
||||
collector::TopDocs, directory::MmapDirectory, schema::*, Index, IndexReader, IndexWriter,
|
||||
ReloadPolicy, Term,
|
||||
};
|
||||
use whatlang::{detect as detect_lang, Lang};
|
||||
|
||||
@@ -34,7 +30,7 @@ impl Searcher {
|
||||
pub fn schema() -> Schema {
|
||||
let tag_indexing = TextOptions::default().set_indexing_options(
|
||||
TextFieldIndexing::default()
|
||||
.set_tokenizer("whitespace_tokenizer")
|
||||
.set_tokenizer("tag_tokenizer")
|
||||
.set_index_option(IndexRecordOption::Basic),
|
||||
);
|
||||
|
||||
@@ -70,17 +66,7 @@ impl Searcher {
|
||||
schema_builder.build()
|
||||
}
|
||||
|
||||
pub fn create(path: &dyn AsRef<Path>) -> Result<Self> {
|
||||
let whitespace_tokenizer =
|
||||
TextAnalyzer::from(tokenizer::WhitespaceTokenizer).filter(LowerCaser);
|
||||
|
||||
let content_tokenizer = TextAnalyzer::from(SimpleTokenizer)
|
||||
.filter(RemoveLongFilter::limit(40))
|
||||
.filter(LowerCaser);
|
||||
|
||||
let property_tokenizer =
|
||||
TextAnalyzer::from(NgramTokenizer::new(2, 8, false)).filter(LowerCaser);
|
||||
|
||||
pub fn create(path: &dyn AsRef<Path>, tokenizers: &SearchTokenizerConfig) -> Result<Self> {
|
||||
let schema = Self::schema();
|
||||
|
||||
create_dir_all(path).map_err(|_| SearcherError::IndexCreationError)?;
|
||||
@@ -92,9 +78,9 @@ impl Searcher {
|
||||
|
||||
{
|
||||
let tokenizer_manager = index.tokenizers();
|
||||
tokenizer_manager.register("whitespace_tokenizer", whitespace_tokenizer);
|
||||
tokenizer_manager.register("content_tokenizer", content_tokenizer);
|
||||
tokenizer_manager.register("property_tokenizer", property_tokenizer);
|
||||
tokenizer_manager.register("tag_tokenizer", tokenizers.tag_tokenizer);
|
||||
tokenizer_manager.register("content_tokenizer", tokenizers.content_tokenizer);
|
||||
tokenizer_manager.register("property_tokenizer", tokenizers.property_tokenizer);
|
||||
} //to please the borrow checker
|
||||
Ok(Self {
|
||||
writer: Mutex::new(Some(
|
||||
@@ -111,26 +97,16 @@ impl Searcher {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open(path: &dyn AsRef<Path>) -> Result<Self> {
|
||||
let whitespace_tokenizer =
|
||||
TextAnalyzer::from(tokenizer::WhitespaceTokenizer).filter(LowerCaser);
|
||||
|
||||
let content_tokenizer = TextAnalyzer::from(SimpleTokenizer)
|
||||
.filter(RemoveLongFilter::limit(40))
|
||||
.filter(LowerCaser);
|
||||
|
||||
let property_tokenizer =
|
||||
TextAnalyzer::from(NgramTokenizer::new(2, 8, false)).filter(LowerCaser);
|
||||
|
||||
pub fn open(path: &dyn AsRef<Path>, tokenizers: &SearchTokenizerConfig) -> Result<Self> {
|
||||
let mut index =
|
||||
Index::open(MmapDirectory::open(path).map_err(|_| SearcherError::IndexOpeningError)?)
|
||||
.map_err(|_| SearcherError::IndexOpeningError)?;
|
||||
|
||||
{
|
||||
let tokenizer_manager = index.tokenizers();
|
||||
tokenizer_manager.register("whitespace_tokenizer", whitespace_tokenizer);
|
||||
tokenizer_manager.register("content_tokenizer", content_tokenizer);
|
||||
tokenizer_manager.register("property_tokenizer", property_tokenizer);
|
||||
tokenizer_manager.register("tag_tokenizer", tokenizers.tag_tokenizer);
|
||||
tokenizer_manager.register("content_tokenizer", tokenizers.content_tokenizer);
|
||||
tokenizer_manager.register("property_tokenizer", tokenizers.property_tokenizer);
|
||||
} //to please the borrow checker
|
||||
let writer = index
|
||||
.writer(50_000_000)
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
#[cfg(feature = "search-lindera")]
|
||||
use lindera_tantivy::tokenizer::LinderaTokenizer;
|
||||
use std::str::CharIndices;
|
||||
use tantivy::tokenizer::{BoxTokenStream, Token, TokenStream, Tokenizer};
|
||||
use tantivy::tokenizer::*;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum TokenizerKind {
|
||||
Simple,
|
||||
Ngram,
|
||||
Whitespace,
|
||||
#[cfg(feature = "search-lindera")]
|
||||
Lindera,
|
||||
}
|
||||
|
||||
impl From<TokenizerKind> for TextAnalyzer {
|
||||
fn from(tokenizer: TokenizerKind) -> TextAnalyzer {
|
||||
use TokenizerKind::*;
|
||||
|
||||
match tokenizer {
|
||||
Simple => TextAnalyzer::from(SimpleTokenizer)
|
||||
.filter(RemoveLongFilter::limit(40))
|
||||
.filter(LowerCaser),
|
||||
Ngram => TextAnalyzer::from(NgramTokenizer::new(2, 8, false)).filter(LowerCaser),
|
||||
Whitespace => TextAnalyzer::from(WhitespaceTokenizer).filter(LowerCaser),
|
||||
#[cfg(feature = "search-lindera")]
|
||||
Lindera => {
|
||||
TextAnalyzer::from(LinderaTokenizer::new("decompose", "")).filter(LowerCaser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tokenize the text by splitting on whitespaces. Pretty much a copy of Tantivy's SimpleTokenizer,
|
||||
/// but not splitting on punctuation
|
||||
|
||||
@@ -1026,6 +1026,7 @@ impl NewUser {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
config::CONFIG,
|
||||
instance::{tests as instance_tests, Instance},
|
||||
search::tests::get_searcher,
|
||||
tests::{db, rockets},
|
||||
@@ -1122,7 +1123,9 @@ pub(crate) mod tests {
|
||||
let inserted = fill_database(conn);
|
||||
|
||||
assert!(User::get(conn, inserted[0].id).is_ok());
|
||||
inserted[0].delete(conn, &get_searcher()).unwrap();
|
||||
inserted[0]
|
||||
.delete(conn, &get_searcher(&CONFIG.search_tokenizers))
|
||||
.unwrap();
|
||||
assert!(User::get(conn, inserted[0].id).is_err());
|
||||
Ok(())
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user