<!--
  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.
-->

# Apache Paimon Full Text Index

Standalone Tantivy-based full-text index library for Apache Paimon-style data
lake storage. The project follows the same shape as `paimon-vector-index`:

- `core`: Rust implementation and v1 storage format.
- `ffi`: C ABI over the Rust core.
- `jni`: Java JNI bridge over the Rust core.
- `java`: public Java API.
- `python`: Python ctypes API over the C ABI.

The index file is self-describing. Readers only need positional `pread` I/O
and do not depend on Paimon manifest metadata.

## Current Status

Implemented:

- Rust writer, reader, v1 envelope, and search.
- Single-field, multi-field, repeated-field string-array, and dotted-path text
  fields.
- Match, fuzzy match (`fuzziness`, `auto`, `max_expansions`,
  `prefix_length`), phrase, boolean, multi-match, and boost-demotion queries.
- C FFI writer/reader/search with query JSON strings, including serialized
  64-bit Roaring row-id filters.
- Java API and JNI bridge.
- Python ctypes package.
- Cross-boundary round-trip tests for Rust core, FFI, Java/JNI, and Python.

Supported tokenizers in this first implementation:

- `default`
- `simple`
- `whitespace`
- `raw`
- `ngram`
- `jieba`

Default tokenizer behavior uses English full-text defaults: lower-case,
stemming, stop-word removal, ASCII folding, max token length 40, and positions.
Set `with-position=false` only when phrase search is not needed.

Readers expose archived Tantivy files through a seek-on-demand directory, so
opening an index reads the envelope and Tantivy metadata without loading all
segment files into memory.

## Build

```bash
cargo test -p paimon-ftindex-core
cargo test -p paimon-ftindex-ffi
cargo build -p paimon-ftindex-ffi
cargo build -p paimon-ftindex-jni
mvn -q -f java/pom.xml test
PYTHONPATH=python python3 -m pytest -q python/tests
```

## Rust Example

```rust
use paimon_ftindex_core::io::{PosWriter, SliceReader};
use paimon_ftindex_core::{FullTextIndexConfig, FullTextIndexReader, FullTextIndexWriter};

let mut writer = FullTextIndexWriter::new(FullTextIndexConfig::new())?;
writer.add_document(1, "Apache Paimon full text search")?;

let mut bytes = Vec::new();
writer.write(&mut PosWriter::new(&mut bytes))?;

let reader = FullTextIndexReader::open(SliceReader::new(bytes))?;
reader.prewarm()?;
let result = reader.search(r#"{"match":{"query":"paimon","column":"text"}}"#, 10)?;
```

Multi-field indexes can be configured with named fields:

```rust
let config = FullTextIndexConfig::new().with_text_fields(["title", "body"]);
let mut writer = FullTextIndexWriter::new(config)?;
writer.add_document_fields(
    1,
    [
        ("title".to_string(), "Apache Paimon".to_string()),
        ("body".to_string(), "lake storage".to_string()),
    ],
)?;
```

When a `match` query omits `column`, the reader searches all indexed text
fields. This lets a Paimon adapter populate extra fields internally without
requiring callers to build a `multi_match` query.

To restrict search to an upstream candidate set, pass a serialized
`RoaringTreemap` of allowed row ids:

```rust
let filtered = reader.search_with_roaring_filter(
    r#"{"match":{"query":"paimon","column":"text"}}"#,
    10,
    roaring_filter_bytes,
)?;
```

## Python Example

```python
from io import BytesIO
from paimon_ftindex import FullTextIndexReader, FullTextIndexWriter

out = BytesIO()
with FullTextIndexWriter({"text-fields": "title,body"}) as writer:
    writer.add_document_fields(1, {"title": "Apache Paimon", "body": "lake storage"})
    writer.write(out)

class Input:
    def __init__(self, data):
        self.data = data
    def pread(self, pos, length):
        return self.data[pos:pos + length]

with FullTextIndexReader(Input(out.getvalue())) as reader:
    reader.prewarm()
    ids, scores = reader.search('{"match":{"query":"paimon"}}', limit=10)
    filtered_ids, filtered_scores = reader.search(
        '{"match":{"query":"paimno","column":"title","fuzziness":1}}',
        limit=10,
        filter_bytes=roaring_filter_bytes,
    )
    metrics = reader.read_metrics()
```

Search APIs accept the query DSL as a JSON string.

`prewarm()` eagerly initializes the underlying search reader and archive cache
before a query burst. `read_metrics()` reports positional read calls/bytes and
archive cache hit/miss counters for tuning reader reuse and object-store access
patterns.
