JingsongLi commented on code in PR #1:
URL: https://github.com/apache/paimon-full-text/pull/1#discussion_r3523329149
##########
core/src/index.rs:
##########
@@ -0,0 +1,384 @@
+use crate::config::{FullTextIndexConfig, FullTextIndexMetadata};
+use crate::error::{FtIndexError, Result};
+use crate::io::{SeekRead, SeekWrite};
+use crate::query::{BooleanOccur, FullTextQuery, MatchOperator};
+use crate::storage::{read_exact_at, read_header, write_envelope,
ArchiveFileEntry, IndexHeader};
+use crate::tokenizer::{TokenizerConfig, TokenizerKind};
+use std::fs;
+use std::path::Path;
+use tantivy::collector::TopDocs;
+use tantivy::directory::{Directory, RamDirectory};
+use tantivy::query::{BooleanQuery, BoostQuery, Occur, Query, QueryParser};
+use tantivy::schema::{IndexRecordOption, NumericOptions, Schema,
TextFieldIndexing, TextOptions};
+use tantivy::tokenizer::{
+ AsciiFoldingFilter, LowerCaser, NgramTokenizer, RawTokenizer,
RemoveLongFilter,
+ SimpleTokenizer, TextAnalyzer, WhitespaceTokenizer,
+};
+use tantivy::{Index, TantivyDocument};
+use tempfile::TempDir;
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct FullTextSearchResult {
+ pub row_ids: Vec<i64>,
+ pub scores: Vec<f32>,
+}
+
+pub struct FullTextIndexWriter {
+ config: FullTextIndexConfig,
+ documents: Vec<(i64, String)>,
+}
+
+impl FullTextIndexWriter {
+ pub fn new(config: FullTextIndexConfig) -> Result<Self> {
+ config.tokenizer.validate()?;
+ Ok(Self {
+ config,
+ documents: Vec::new(),
+ })
+ }
+
+ pub fn add_document(&mut self, row_id: i64, text: impl Into<String>) ->
Result<()> {
+ if row_id < 0 {
+ return Err(FtIndexError::InvalidStorage(format!(
+ "row id must be non-negative, got {row_id}"
+ )));
+ }
+ self.documents.push((row_id, text.into()));
+ Ok(())
+ }
+
+ pub fn write<W: SeekWrite>(&mut self, output: &mut W) -> Result<()> {
+ let temp_dir = TempDir::new()?;
+ let schema = build_schema(&self.config);
+ let mut index = Index::create_in_dir(temp_dir.path(), schema.clone())?;
+ register_tokenizer(&mut index, &self.config.tokenizer)?;
+ let row_id_field = schema
+ .get_field(&self.config.row_id_field)
+ .map_err(|_| FtIndexError::InvalidStorage("missing row_id
field".to_string()))?;
+ let text_field = schema
+ .get_field(&self.config.text_field)
+ .map_err(|_| FtIndexError::InvalidStorage("missing text
field".to_string()))?;
+
+ {
+ let mut index_writer = index.writer(50_000_000)?;
+ for (row_id, text) in &self.documents {
+ let mut doc = TantivyDocument::new();
+ doc.add_u64(row_id_field, *row_id as u64);
+ doc.add_text(text_field, text);
+ index_writer.add_document(doc)?;
+ }
+ index_writer.commit()?;
+ }
+
+ let files = collect_index_files(temp_dir.path())?;
+ let mut offset = 0u64;
+ let mut entries = Vec::with_capacity(files.len());
+ for (name, data) in &files {
+ entries.push(ArchiveFileEntry {
+ name: name.clone(),
+ offset,
+ length: data.len() as u64,
+ });
+ offset += data.len() as u64;
+ }
+
+ let header = IndexHeader {
+ metadata: FullTextIndexMetadata {
+ format_version: crate::storage::FORMAT_VERSION,
+ config: self.config.clone(),
+ document_count: self.documents.len() as u64,
+ tantivy_version: tantivy::version().to_string(),
+ },
+ files: entries,
+ };
+ write_envelope(output, &header, &files)
+ }
+}
+
+pub struct FullTextIndexReader<R> {
+ _input: R,
+ index: Index,
+ metadata: FullTextIndexMetadata,
+}
+
+impl<R: SeekRead> FullTextIndexReader<R> {
+ pub fn open(mut input: R) -> Result<Self> {
+ let (header, body_start) = read_header(&mut input)?;
+ let directory = RamDirectory::create();
+ for file in &header.files {
+ let mut data = vec![0u8; file.length as usize];
+ read_exact_at(&mut input, body_start + file.offset, &mut data)?;
+ directory.atomic_write(Path::new(&file.name), &data)?;
+ }
+ let mut index = Index::open(directory)?;
+ register_tokenizer(&mut index, &header.metadata.config.tokenizer)?;
+ Ok(Self {
+ _input: input,
+ index,
+ metadata: header.metadata,
+ })
+ }
+
+ pub fn optimize_for_search(&mut self) -> Result<()> {
+ Ok(())
+ }
+
+ pub fn metadata(&self) -> &FullTextIndexMetadata {
+ &self.metadata
+ }
+
+ pub fn search(&mut self, query: FullTextQuery, limit: usize) ->
Result<FullTextSearchResult> {
+ if limit == 0 {
+ return Err(FtIndexError::InvalidQuery(
+ "search limit must be positive".to_string(),
+ ));
+ }
+ let reader = self.index.reader()?;
+ let searcher = reader.searcher();
+ let tantivy_query = build_query(&self.index, &self.metadata.config,
&query)?;
+ let top_docs = searcher.search(&tantivy_query,
&TopDocs::with_limit(limit))?;
+ let mut row_ids = Vec::with_capacity(top_docs.len());
+ let mut scores = Vec::with_capacity(top_docs.len());
+ for (score, doc_address) in top_docs {
+ let segment_reader =
searcher.segment_reader(doc_address.segment_ord);
+ let row_id_column = segment_reader
+ .fast_fields()
+ .u64(&self.metadata.config.row_id_field)?
+ .first_or_default_col(0);
+ row_ids.push(row_id_column.get_val(doc_address.doc_id) as i64);
+ scores.push(score);
+ }
+ Ok(FullTextSearchResult { row_ids, scores })
+ }
+}
+
+fn build_schema(config: &FullTextIndexConfig) -> Schema {
+ let mut builder = Schema::builder();
+ builder.add_u64_field(
+ &config.row_id_field,
+ NumericOptions::default()
+ .set_fast()
+ .set_stored()
+ .set_indexed(),
+ );
+ let index_option = if config.tokenizer.with_position {
+ IndexRecordOption::WithFreqsAndPositions
+ } else {
+ IndexRecordOption::WithFreqs
+ };
+ let tokenizer_name = tokenizer_name(&config.tokenizer);
+ let indexing = TextFieldIndexing::default()
+ .set_tokenizer(tokenizer_name)
+ .set_index_option(index_option);
+ builder.add_text_field(
+ &config.text_field,
+ TextOptions::default().set_indexing_options(indexing),
+ );
+ builder.build()
+}
+
+fn tokenizer_name(config: &TokenizerConfig) -> &'static str {
+ match config.tokenizer {
+ TokenizerKind::Default if !needs_custom_default(config) => "default",
+ TokenizerKind::Default | TokenizerKind::Simple => "paimon_custom",
+ TokenizerKind::Whitespace => "paimon_custom",
+ TokenizerKind::Raw => "paimon_custom",
+ TokenizerKind::Ngram => "paimon_ngram",
+ TokenizerKind::Jieba => "paimon_jieba",
+ }
+}
+
+fn needs_custom_default(config: &TokenizerConfig) -> bool {
+ !config.lower_case
+ || config.max_token_length != 40
+ || config.ascii_folding
+ || config.stem
+ || config.remove_stop_words
+ || !config.stop_words.is_empty()
+}
+
+fn register_tokenizer(index: &mut Index, config: &TokenizerConfig) ->
Result<()> {
+ match config.tokenizer {
+ TokenizerKind::Default if !needs_custom_default(config) => Ok(()),
+ TokenizerKind::Jieba => Err(FtIndexError::InvalidOption {
+ key: "tokenizer".to_string(),
+ message: "jieba tokenizer is not enabled in this first
implementation".to_string(),
+ }),
+ _ => {
+ let analyzer = build_text_analyzer(config)?;
+ index
+ .tokenizers()
+ .register(tokenizer_name(config), analyzer);
+ Ok(())
+ }
+ }
+}
+
+fn build_text_analyzer(config: &TokenizerConfig) -> Result<TextAnalyzer> {
+ if config.stem || config.remove_stop_words ||
!config.stop_words.is_empty() {
+ return Err(FtIndexError::InvalidOption {
+ key: "tokenizer filters".to_string(),
+ message: "stemming and stop-word filters are not enabled in this
first implementation"
+ .to_string(),
+ });
+ }
+ let mut builder = match config.tokenizer {
+ TokenizerKind::Default | TokenizerKind::Simple => {
+ TextAnalyzer::builder(SimpleTokenizer::default()).dynamic()
+ }
+ TokenizerKind::Whitespace => {
+ TextAnalyzer::builder(WhitespaceTokenizer::default()).dynamic()
+ }
+ TokenizerKind::Raw =>
TextAnalyzer::builder(RawTokenizer::default()).dynamic(),
+ TokenizerKind::Ngram => {
+ let tokenizer = NgramTokenizer::new(
+ config.ngram_min_gram,
+ config.ngram_max_gram,
+ config.ngram_prefix_only,
+ )
+ .map_err(|e| FtIndexError::InvalidOption {
+ key: "ngram".to_string(),
+ message: e.to_string(),
+ })?;
+ TextAnalyzer::builder(tokenizer).dynamic()
+ }
+ TokenizerKind::Jieba => {
+ return Err(FtIndexError::InvalidOption {
+ key: "tokenizer".to_string(),
+ message: "jieba tokenizer is not enabled in this first
implementation".to_string(),
+ })
+ }
+ };
+ builder =
builder.filter_dynamic(RemoveLongFilter::limit(config.max_token_length));
+ if config.lower_case {
+ builder = builder.filter_dynamic(LowerCaser);
+ }
+ if config.ascii_folding {
+ builder = builder.filter_dynamic(AsciiFoldingFilter);
+ }
+ Ok(builder.build())
+}
+
+fn collect_index_files(path: &Path) -> Result<Vec<(String, Vec<u8>)>> {
+ let mut paths = Vec::new();
+ for entry in fs::read_dir(path)? {
+ let entry = entry?;
+ if entry.file_type()?.is_file() {
+ paths.push(entry.path());
+ }
+ }
+ paths.sort();
+ let mut files = Vec::with_capacity(paths.len());
+ for path in paths {
+ let name = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .ok_or_else(|| FtIndexError::InvalidStorage("non-utf8 file
name".to_string()))?
+ .to_string();
+ if name.ends_with(".lock") {
+ continue;
+ }
+ files.push((name, fs::read(path)?));
+ }
+ Ok(files)
+}
+
+fn build_query(
+ index: &Index,
+ config: &FullTextIndexConfig,
+ query: &FullTextQuery,
+) -> Result<Box<dyn Query>> {
+ match query {
+ FullTextQuery::Match {
+ column,
+ terms,
+ operator,
+ boost,
+ } => {
+ validate_column(config, column)?;
+ let text_field = index
+ .schema()
+ .get_field(&config.text_field)
+ .map_err(|_| FtIndexError::InvalidQuery("missing text
field".to_string()))?;
+ let mut parser = QueryParser::for_index(index, vec![text_field]);
+ if *operator == MatchOperator::And {
+ parser.set_conjunction_by_default();
+ }
+ let parsed = parser
+ .parse_query(terms)
+ .map_err(|e| FtIndexError::InvalidQuery(e.to_string()))?;
+ if (*boost - 1.0).abs() > f32::EPSILON {
+ Ok(Box::new(BoostQuery::new(parsed, *boost)))
+ } else {
+ Ok(parsed)
+ }
+ }
+ FullTextQuery::MatchPhrase {
+ column,
+ terms,
+ slop,
+ } => {
+ validate_column(config, column)?;
+ if !config.tokenizer.with_position {
+ return Err(FtIndexError::InvalidQuery(
+ "phrase query requires positions".to_string(),
+ ));
+ }
+ let text_field = index
+ .schema()
+ .get_field(&config.text_field)
+ .map_err(|_| FtIndexError::InvalidQuery("missing text
field".to_string()))?;
+ let parser = QueryParser::for_index(index, vec![text_field]);
+ let escaped = terms.replace('\\', "\\\\").replace('"', "\\\"");
+ let query_text = if *slop == 0 {
+ format!("\"{escaped}\"")
+ } else {
+ format!("\"{escaped}\"~{slop}")
+ };
+ parser
+ .parse_query(&query_text)
+ .map_err(|e| FtIndexError::InvalidQuery(e.to_string()))
+ }
+ FullTextQuery::Boolean { queries } => {
+ if queries.is_empty() {
+ return Err(FtIndexError::InvalidQuery(
+ "boolean query must contain at least one
clause".to_string(),
+ ));
+ }
+ let mut children = Vec::with_capacity(queries.len());
+ for (occur, child) in queries {
+ let occur = match occur {
+ BooleanOccur::Should => Occur::Should,
+ BooleanOccur::Must => Occur::Must,
+ BooleanOccur::MustNot => Occur::MustNot,
+ };
+ children.push((occur, build_query(index, config, child)?));
+ }
+ Ok(Box::new(BooleanQuery::new(children)))
+ }
+ FullTextQuery::Boost {
+ positive,
+ negative,
+ negative_boost,
+ } => {
+ let boosted_negative = Box::new(BoostQuery::new(
+ build_query(index, config, negative)?,
+ *negative_boost,
+ ));
+ Ok(Box::new(BooleanQuery::new(vec![
+ (Occur::Must, build_query(index, config, positive)?),
+ (Occur::Should, boosted_negative),
Review Comment:
Fixed in e01d9a3: Boost now uses a custom DemoteQuery/DemoteScorer. The
result set stays equal to the positive query, and if a positive match also
matches the negative query, its positive score is multiplied by negative_boost
instead of adding the negative score. Added
boost_query_demotes_negative_matches to cover the ranking case from this
comment, and updated the design doc wording.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]