|
16 | 16 | // under the License. |
17 | 17 |
|
18 | 18 | //! Reader over the `paimon-ftindex-core` v1 archive format. |
| 19 | +
|
| 20 | +use paimon_ftindex_core::io::SliceReader; |
| 21 | +use paimon_ftindex_core::{FullTextIndexReader, FullTextSearchResult}; |
| 22 | +use roaring::RoaringTreemap; |
| 23 | + |
| 24 | +use crate::io::InputFile; |
| 25 | + |
| 26 | +/// Search hits from a full-text archive: parallel row-id and score arrays, |
| 27 | +/// ordered best-score-first (as returned by the engine). |
| 28 | +#[derive(Debug, Clone)] |
| 29 | +pub struct FullTextHits { |
| 30 | + pub row_ids: Vec<i64>, |
| 31 | + pub scores: Vec<f32>, |
| 32 | +} |
| 33 | + |
| 34 | +impl From<FullTextSearchResult> for FullTextHits { |
| 35 | + fn from(r: FullTextSearchResult) -> Self { |
| 36 | + Self { |
| 37 | + row_ids: r.row_ids, |
| 38 | + scores: r.scores, |
| 39 | + } |
| 40 | + } |
| 41 | +} |
| 42 | + |
| 43 | +/// Reader over a single full-text index archive, backed by the shared |
| 44 | +/// `paimon-ftindex-core` engine (v1 archive format). |
| 45 | +pub struct FullTextArchiveReader { |
| 46 | + inner: FullTextIndexReader<SliceReader>, |
| 47 | +} |
| 48 | + |
| 49 | +impl FullTextArchiveReader { |
| 50 | + /// Read the whole archive from `input` into memory and open the engine |
| 51 | + /// reader over it. Archives are single index files, so a whole-read keeps |
| 52 | + /// peak memory at one archive. |
| 53 | + pub async fn from_input_file(input: &InputFile) -> crate::Result<Self> { |
| 54 | + let bytes = input.read().await?; |
| 55 | + let reader = |
| 56 | + FullTextIndexReader::open(SliceReader::new(bytes.to_vec())).map_err(map_ft_err)?; |
| 57 | + Ok(Self { inner: reader }) |
| 58 | + } |
| 59 | + |
| 60 | + /// Search with a JSON DSL query (see the engine's query spec), returning up |
| 61 | + /// to `limit` best-scoring hits. |
| 62 | + pub fn search(&self, query_json: &str, limit: usize) -> crate::Result<FullTextHits> { |
| 63 | + self.inner |
| 64 | + .search(query_json, limit) |
| 65 | + .map(Into::into) |
| 66 | + .map_err(map_ft_err) |
| 67 | + } |
| 68 | + |
| 69 | + /// Like [`search`](Self::search) but restricts results to `include_row_ids` |
| 70 | + /// (the live-row allow-list). |
| 71 | + pub fn search_with_include( |
| 72 | + &self, |
| 73 | + query_json: &str, |
| 74 | + limit: usize, |
| 75 | + include_row_ids: RoaringTreemap, |
| 76 | + ) -> crate::Result<FullTextHits> { |
| 77 | + let mut filter_bytes = Vec::with_capacity(include_row_ids.serialized_size()); |
| 78 | + include_row_ids |
| 79 | + .serialize_into(&mut filter_bytes) |
| 80 | + .map_err(|e| crate::Error::UnexpectedError { |
| 81 | + message: format!("failed to serialize row-id filter: {e}"), |
| 82 | + source: None, |
| 83 | + })?; |
| 84 | + self.inner |
| 85 | + .search_with_roaring_filter(query_json, limit, &filter_bytes) |
| 86 | + .map(Into::into) |
| 87 | + .map_err(map_ft_err) |
| 88 | + } |
| 89 | +} |
| 90 | + |
| 91 | +fn map_ft_err(e: paimon_ftindex_core::FtIndexError) -> crate::Error { |
| 92 | + crate::Error::UnexpectedError { |
| 93 | + message: format!("full-text index engine error: {e}"), |
| 94 | + source: None, |
| 95 | + } |
| 96 | +} |
| 97 | + |
| 98 | +#[cfg(test)] |
| 99 | +mod tests { |
| 100 | + use super::*; |
| 101 | + use crate::io::FileIOBuilder; |
| 102 | + use bytes::Bytes; |
| 103 | + use paimon_ftindex_core::io::PosWriter; |
| 104 | + use paimon_ftindex_core::{FullTextIndexConfig, FullTextIndexWriter}; |
| 105 | + |
| 106 | + // Build a tiny archive in memory using the core writer, return its bytes. |
| 107 | + fn build_archive(docs: &[(i64, &str)]) -> Vec<u8> { |
| 108 | + let mut writer = FullTextIndexWriter::new(FullTextIndexConfig::new()).unwrap(); |
| 109 | + for (row_id, text) in docs { |
| 110 | + writer.add_document(*row_id, (*text).to_string()).unwrap(); |
| 111 | + } |
| 112 | + let mut out = PosWriter::new(Vec::<u8>::new()); |
| 113 | + writer.write(&mut out).unwrap(); |
| 114 | + out.into_inner() |
| 115 | + } |
| 116 | + |
| 117 | + async fn archive_input(bytes: Vec<u8>) -> crate::io::InputFile { |
| 118 | + let file_io = FileIOBuilder::new("memory").build().unwrap(); |
| 119 | + let path = "memory:/ft-pr1-roundtrip.archive"; |
| 120 | + let output = file_io.new_output(path).unwrap(); |
| 121 | + output.write(Bytes::from(bytes)).await.unwrap(); |
| 122 | + output.to_input_file() |
| 123 | + } |
| 124 | + |
| 125 | + #[tokio::test] |
| 126 | + async fn test_round_trip_search_returns_expected_rows() { |
| 127 | + let bytes = build_archive(&[ |
| 128 | + (0, "alpha bravo charlie"), |
| 129 | + (1, "bravo delta"), |
| 130 | + (2, "echo foxtrot"), |
| 131 | + ]); |
| 132 | + let input = archive_input(bytes).await; |
| 133 | + let reader = FullTextArchiveReader::from_input_file(&input).await.unwrap(); |
| 134 | + |
| 135 | + let hits = reader.search(r#"{"match":{"query":"bravo"}}"#, 10).unwrap(); |
| 136 | + let mut ids = hits.row_ids.clone(); |
| 137 | + ids.sort_unstable(); |
| 138 | + assert_eq!(ids, vec![0, 1]); |
| 139 | + assert_eq!(hits.scores.len(), hits.row_ids.len()); |
| 140 | + } |
| 141 | +} |
0 commit comments