Skip to content

Commit 97ac666

Browse files
committed
feat(ftindex): read and search full-text archives via paimon-ftindex-core
1 parent 571aae8 commit 97ac666

1 file changed

Lines changed: 123 additions & 0 deletions

File tree

crates/paimon/src/ftindex/reader.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,126 @@
1616
// under the License.
1717

1818
//! 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

Comments
 (0)