Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ project(FTSExtension)

set(EXTENSION_BASE_FOLDER "src")
set(SNOWBALL_BASE_FOLDER "${CMAKE_CURRENT_SOURCE_DIR}/third_party/snowball")
set(DUCKDB_ICU_BASE_FOLDER "${CMAKE_CURRENT_SOURCE_DIR}/duckdb/extension/icu")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This compiles against ICU headers from FTS’s pinned duckdb submodule, but below we link duckdb_icu_* object targets from the host DuckDB build. That mixes two DuckDB/ICU revisions with no compatibility guarantee. Can we source both headers and objects consistently from the host build (or consistently from the submodule)?

include_directories(
${EXTENSION_BASE_FOLDER}/include
${SNOWBALL_BASE_FOLDER}/include
${SNOWBALL_BASE_FOLDER}/libstemmer
${SNOWBALL_BASE_FOLDER}/runtime)
${SNOWBALL_BASE_FOLDER}/runtime
${CMAKE_CURRENT_SOURCE_DIR}/duckdb/third_party/utf8proc/include
${DUCKDB_ICU_BASE_FOLDER}/third_party/icu/common
${DUCKDB_ICU_BASE_FOLDER}/third_party/icu/i18n)

file(GLOB SNOWBALL_STEMMER_SOURCES CONFIGURE_DEPENDS ${SNOWBALL_BASE_FOLDER}/src_c/stem_*.c)

Expand All @@ -27,6 +31,19 @@ build_static_extension(${TARGET_NAME} ${EXTENSION_SOURCES})
set(PARAMETERS "-warnings")
build_loadable_extension(${TARGET_NAME} ${PARAMETERS} ${EXTENSION_SOURCES})

if(TARGET duckdb_icu_common)
target_sources(${EXTENSION_NAME} PRIVATE $<TARGET_OBJECTS:duckdb_icu_common>)
target_sources(${LOADABLE_EXTENSION_NAME} PRIVATE $<TARGET_OBJECTS:duckdb_icu_common>)
endif()
if(TARGET duckdb_icu_common_unity)
target_sources(${EXTENSION_NAME} PRIVATE $<TARGET_OBJECTS:duckdb_icu_common_unity>)
target_sources(${LOADABLE_EXTENSION_NAME} PRIVATE $<TARGET_OBJECTS:duckdb_icu_common_unity>)
endif()
if(TARGET duckdb_icu_stubdata)
target_sources(${EXTENSION_NAME} PRIVATE $<TARGET_OBJECTS:duckdb_icu_stubdata>)
target_sources(${LOADABLE_EXTENSION_NAME} PRIVATE $<TARGET_OBJECTS:duckdb_icu_stubdata>)
endif()

install(
TARGETS ${EXTENSION_NAME}
EXPORT "${DUCKDB_EXPORT_SET}"
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The extension adds two `PRAGMA` statements to DuckDB: one to create, and one to

```python
create_fts_index(input_table, input_id, *input_values, stemmer = 'porter',
tokenizer = 'regex',
stopwords = 'english',
ignore = "[0-9!@#$%^&*()_+={}\\[\\]:;<>,.?~\\\\/\\|''\"`-]+",
strip_accents = 1, lower = 1, overwrite = 0,
Expand All @@ -36,8 +37,9 @@ create_fts_index(input_table, input_id, *input_values, stemmer = 'porter',
| `input_id` | `VARCHAR` | Column name of document identifier, e.g., `'document_identifier'` |
| `input_values…` | `VARCHAR` | Column names of the text fields to be indexed (vararg), e.g., `'text_field_1'`, `'text_field_2'`, ..., `'text_field_N'`, or `'\*'` for all columns in input_table of type `VARCHAR` |
| `stemmer` | `VARCHAR` | The type of stemmer to be used. One of `'arabic'`, `'armenian'`, `'basque'`, `'catalan'`, `'czech'`, `'danish'`, `'dutch'`, `'dutch_porter'`, `'english'`, `'esperanto'`, `'estonian'`, `'finnish'`, `'french'`, `'german'`, `'greek'`, `'hindi'`, `'hungarian'`, `'indonesian'`, `'irish'`, `'italian'`, `'lithuanian'`, `'nepali'`, `'norwegian'`, `'persian'`, `'polish'`, `'porter'`, `'portuguese'`, `'romanian'`, `'russian'`, `'serbian'`, `'sesotho'`, `'spanish'`, `'swedish'`, `'tamil'`, `'turkish'`, `'yiddish'`, or `'none'` if no stemming is to be used. Defaults to `'porter'` |
| `tokenizer` | `VARCHAR` | Tokenizer to use. `'regex'` keeps the legacy regex-split behavior. `'opensearch_standard'` uses an OpenSearch/Lucene standard-tokenizer compatibility mode that splits Han, Hiragana, and Katakana into single-character tokens while preserving word runs for scripts such as Hebrew, Cyrillic, Arabic, Latin, and Hangul. Defaults to `'regex'` |
| `stopwords` | `VARCHAR` | Qualified name of table containing a single `VARCHAR` column containing the desired stopwords, or `'none'` if no stopwords are to be used. Defaults to `'english'` for a pre-defined list of 571 English stopwords |
| `ignore` | `VARCHAR` | Regular expression of patterns to be ignored. Defaults to a punctuation and digit pattern |
| `ignore` | `VARCHAR` | Regular expression of patterns to be ignored by the `'regex'` tokenizer. Defaults to a punctuation and digit pattern |
| `strip_accents` | `BOOLEAN` | Whether to remove accents (e.g., convert `á` to `a`). Defaults to `1` |
| `lower` | `BOOLEAN` | Whether to convert all text to lowercase. Defaults to `1` |
| `overwrite` | `BOOLEAN` | Whether to overwrite an existing index on a table. Defaults to `0` |
Expand Down
2 changes: 2 additions & 0 deletions extension_config.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ if (LINK_FTS_STATICALLY)
set(FTS_DONT_LINK "")
endif()

duckdb_extension_load(icu)

# Extension from this repo
duckdb_extension_load(fts
SOURCE_DIR ${CMAKE_CURRENT_LIST_DIR}
Expand Down
3 changes: 3 additions & 0 deletions src/fts_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
'third_party/snowball/libstemmer',
'third_party/snowball/runtime',
'third_party/snowball/src_c',
'third_party/utf8proc/include',
'extension/icu/third_party/icu/common',
'extension/icu/third_party/icu/i18n',
]
]
# source files
Expand Down
144 changes: 144 additions & 0 deletions src/fts_extension.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,152 @@
#include "duckdb.hpp"
#include "duckdb/common/exception.hpp"
#include "duckdb/common/string_util.hpp"
#include "duckdb/common/vector/flat_vector.hpp"
#include "duckdb/common/vector/list_vector.hpp"
#include "duckdb/common/vector/string_vector.hpp"
#include "duckdb/common/vector/vector_writer.hpp"
#include "duckdb/function/pragma_function.hpp"
#include "duckdb/function/scalar_function.hpp"
#include "duckdb/main/extension/extension_loader.hpp"
#include "fts_indexing.hpp"
#include "libstemmer.h"
#include "unicode/uchar.h"
#include "unicode/uscript.h"
#include "utf8proc_wrapper.hpp"

namespace duckdb {

static bool IsOpenSearchStandardSingleTokenScript(UScriptCode script) {
return script == USCRIPT_HAN || script == USCRIPT_HIRAGANA;
}

static bool IsOpenSearchStandardEmoji(UChar32 codepoint) {
return u_hasBinaryProperty(codepoint, UCHAR_EMOJI_PRESENTATION) ||
u_hasBinaryProperty(codepoint, UCHAR_EXTENDED_PICTOGRAPHIC);
}

static bool IsOpenSearchStandardIntraTokenPunctuation(UChar32 codepoint) {
return codepoint == '\'' || codepoint == '.' || codepoint == '_';
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only treats ASCII ' as intra-token punctuation. As a result, common text such as It’s and l’amour is split into It, s / l, amour, whereas OpenSearch’s standard tokenizer preserves It’s as one token. Could we implement the relevant UAX #29 apostrophe handling (at least U+2019), and add coverage for it?

# name: test/temp.test
# group: [fts]

require fts

query T
SELECT token
FROM unnest(fts_tokenize_opensearch_standard('It’s')) AS t(token)
----
It’s


static bool IsJapaneseProlongedSoundMark(UChar32 codepoint) {
return codepoint == 0x30FC || codepoint == 0xFF70;
}

static bool IsOpenSearchStandardTokenChar(UChar32 codepoint,
UScriptCode script) {
if (u_isUWhiteSpace(codepoint) || u_ispunct(codepoint)) {
return false;
}
return u_isalnum(codepoint) ||
u_hasBinaryProperty(codepoint, UCHAR_ALPHABETIC) ||
script == USCRIPT_KATAKANA || IsJapaneseProlongedSoundMark(codepoint);
}

static bool IsOpenSearchStandardContinuationChar(UChar32 codepoint,
UScriptCode script) {
return !IsOpenSearchStandardSingleTokenScript(script) &&
!IsOpenSearchStandardEmoji(codepoint) &&
IsOpenSearchStandardTokenChar(codepoint, script);
}

template <class LIST_WRITER>
static void TokenizeOpenSearchStandard(string_t input, LIST_WRITER &list) {
auto input_data = input.GetData();
auto input_size = input.GetSize();
idx_t token_start = 0;
idx_t token_size = 0;

auto flush_token = [&]() {
if (token_size == 0) {
return;
}
list.WriteElement().WriteStringRef(string_t(
input_data + token_start, UnsafeNumericCast<uint32_t>(token_size)));
token_size = 0;
};

auto decode_codepoint = [&](idx_t pos, UChar32 &codepoint, int &char_size,
UScriptCode &script) -> bool {
if (pos >= input_size) {
return false;
}
char_size = 0;
codepoint = Utf8Proc::UTF8ToCodepoint(input_data + pos, char_size,
input_size - pos);
if (char_size <= 0) {
return false;
}
UErrorCode status = U_ZERO_ERROR;
script = uscript_getScript(codepoint, &status);
return U_SUCCESS(status);
};

for (idx_t pos = 0; pos < input_size;) {
int char_size = 0;
UChar32 codepoint = 0;
UScriptCode script = USCRIPT_UNKNOWN;
if (!decode_codepoint(pos, codepoint, char_size, script)) {
flush_token();
pos++;
continue;
}

if (IsOpenSearchStandardSingleTokenScript(script) ||
IsOpenSearchStandardEmoji(codepoint)) {
flush_token();
list.WriteElement().WriteStringRef(
string_t(input_data + pos, UnsafeNumericCast<uint32_t>(char_size)));
} else if (IsOpenSearchStandardIntraTokenPunctuation(codepoint) &&
token_size > 0) {
UChar32 next_codepoint = 0;
int next_char_size = 0;
UScriptCode next_script = USCRIPT_UNKNOWN;
auto next_pos = pos + UnsafeNumericCast<idx_t>(char_size);
if (decode_codepoint(next_pos, next_codepoint, next_char_size,
next_script) &&
IsOpenSearchStandardContinuationChar(next_codepoint, next_script)) {
token_size += UnsafeNumericCast<idx_t>(char_size);
} else {
flush_token();
}
} else if (IsOpenSearchStandardTokenChar(codepoint, script)) {
if (token_size == 0) {
token_start = pos;
}
token_size += UnsafeNumericCast<idx_t>(char_size);
} else {
flush_token();
}
pos += UnsafeNumericCast<idx_t>(char_size);
}
flush_token();
}

static void OpenSearchStandardTokenizeFunction(DataChunk &args,
ExpressionState &state,
Vector &result) {
auto input_entries = args.data[0].Values<string_t>();

D_ASSERT(result.GetType().id() == LogicalTypeId::LIST);
result.SetVectorType(VectorType::FLAT_VECTOR);

auto list_writer =
FlatVector::Writer<VectorListType<string_t>>(result, args.size());
for (idx_t i = 0; i < args.size(); i++) {
auto input_entry = input_entries[i];
if (!input_entry.IsValid()) {
list_writer.WriteNull();
continue;
}
auto list = list_writer.WriteDynamicList();
TokenizeOpenSearchStandard(input_entry.GetValue(), list);
}

StringVector::AddHeapReference(ListVector::GetChildMutable(result),
args.data[0]);
}

static void StemFunction(DataChunk &args, ExpressionState &state,
Vector &result) {
auto &input_vector = args.data[0];
Expand Down Expand Up @@ -57,11 +195,16 @@ static void LoadInternal(ExtensionLoader &loader) {

ScalarFunction stem_func("stem", {LogicalType::VARCHAR, LogicalType::VARCHAR},
LogicalType::VARCHAR, StemFunction);
ScalarFunction opensearch_standard_tokenize_func(
"fts_tokenize_opensearch_standard", {LogicalType::VARCHAR},
LogicalType::LIST(LogicalType::VARCHAR),
OpenSearchStandardTokenizeFunction);

auto create_fts_index_func = PragmaFunction::PragmaCall(
"create_fts_index", FTSIndexing::CreateFTSIndexQuery,
{LogicalType::VARCHAR, LogicalType::VARCHAR}, LogicalType::VARCHAR);
create_fts_index_func.named_parameters["stemmer"] = LogicalType::VARCHAR;
create_fts_index_func.named_parameters["tokenizer"] = LogicalType::VARCHAR;
create_fts_index_func.named_parameters["stopwords"] = LogicalType::VARCHAR;
create_fts_index_func.named_parameters["ignore"] = LogicalType::VARCHAR;
create_fts_index_func.named_parameters["strip_accents"] =
Expand All @@ -78,6 +221,7 @@ static void LoadInternal(ExtensionLoader &loader) {
"drop_fts_index", FTSIndexing::DropFTSIndexQuery, {LogicalType::VARCHAR});

loader.RegisterFunction(stem_func);
loader.RegisterFunction(opensearch_standard_tokenize_func);
loader.RegisterFunction(create_fts_index_func);
loader.RegisterFunction(drop_fts_index_func);
}
Expand Down
31 changes: 24 additions & 7 deletions src/fts_indexing.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,24 @@ static string StopwordsScript(const string &stopwords) {
return "INSERT INTO %fts_schema%.stopwords SELECT * FROM " + stopwords + ";";
}

static string TokenizeMacroScript(const string &ignore, bool strip_accents,
bool lower) {
static string NormalizeTokenInputExpression(bool strip_accents, bool lower) {
string expr = "s::VARCHAR";
if (strip_accents) {
expr = "strip_accents(" + expr + ")";
}
if (lower) {
expr = "lower(" + expr + ")";
}
return expr;
}

static string TokenizeMacroScript(const string &tokenizer, const string &ignore,
bool strip_accents, bool lower) {
auto expr = NormalizeTokenInputExpression(strip_accents, lower);
if (tokenizer == "opensearch_standard") {
expr = "fts_tokenize_opensearch_standard(" + expr + ")";
return "CREATE MACRO %fts_schema%.tokenize(s) AS " + expr + ";";
}
auto delimiter = "(" + ignore + ")|\\s+";
expr = "string_split_regex(" + expr + ", $$" + delimiter + "$$)";
return "CREATE MACRO %fts_schema%.tokenize(s) AS " + expr + ";";
Expand Down Expand Up @@ -1389,16 +1398,16 @@ static string IndexingScript(ClientContext &context, QualifiedName &qname,
const string &input_id,
const vector<string> &input_values,
const string &stemmer, const string &stopwords,
const string &ignore, bool strip_accents,
bool lower, bool incremental, bool cluster_terms,
bool layered_search) {
const string &tokenizer, const string &ignore,
bool strip_accents, bool lower, bool incremental,
bool cluster_terms, bool layered_search) {
string result;
if (TableExists(context, qname) && SupportsFTSTriggers(context, qname)) {
result += DropFTSTriggersScript(qname);
}
result += SchemaSetupScript();
result += StopwordsScript(stopwords);
result += TokenizeMacroScript(ignore, strip_accents, lower);
result += TokenizeMacroScript(tokenizer, ignore, strip_accents, lower);
result += IndexTablesScript(input_id, input_values, stemmer, stopwords,
GetFTSBuildTermsTable(qname),
GetFTSBuildDictTable(qname), cluster_terms);
Expand Down Expand Up @@ -1473,6 +1482,7 @@ string FTSIndexing::CreateFTSIndexQuery(ClientContext &context,
};

const string stemmer = get_string("stemmer", "porter");
const string tokenizer = get_string("tokenizer", "regex");
const string stopwords = get_string("stopwords", "english");
const string ignore =
get_string("ignore", "[0-9!@#$%^&*()_+={}\\[\\]:;<>,.?~\\\\/\\|''\"`-]+");
Expand All @@ -1483,6 +1493,13 @@ string FTSIndexing::CreateFTSIndexQuery(ClientContext &context,
const bool layered_search = get_bool("layered_search", false);
const bool cluster_terms = get_bool("cluster_terms", false) || layered_search;

if (tokenizer != "regex" && tokenizer != "opensearch_standard") {
throw InvalidInputException(
"Unrecognized tokenizer '%s'. Supported tokenizers are: ['regex', "
"'opensearch_standard']",
tokenizer);
}

if (stopwords != "english" && stopwords != "none") {
auto sw_qname = GetQualifiedName(context, stopwords);
Catalog::GetEntry<TableCatalogEntry>(context, sw_qname);
Expand Down Expand Up @@ -1549,7 +1566,7 @@ string FTSIndexing::CreateFTSIndexQuery(ClientContext &context,
}

return IndexingScript(context, qname, doc_id, doc_values, stemmer, stopwords,
ignore, strip_accents, lower, incremental,
tokenizer, ignore, strip_accents, lower, incremental,
cluster_terms, layered_search);
}

Expand Down
Loading
Loading