Optimize CJK wildcard search candidates
Browse filesReuse normalized metadata text and prefilter wildcard candidates through existing CJK postings without changing tokenizer or index format.
app.py
CHANGED
|
@@ -98,6 +98,14 @@ def literal_query_tokens(text: str) -> list[str]:
|
|
| 98 |
if not TOKEN_RE.fullmatch(normalized[index + 1])
|
| 99 |
))
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
def decode_doc_numbers(payload: bytes, candidates: set[int] | None = None) -> set[int]:
|
| 102 |
results = set()
|
| 103 |
number = shift = previous = 0
|
|
@@ -286,15 +294,18 @@ class FulltextDatabases:
|
|
| 286 |
normalized_query = normalize_text(query)
|
| 287 |
try:
|
| 288 |
if has_wildcard_query(normalized_query):
|
|
|
|
|
|
|
| 289 |
fixed_parts = [part for part in re.split(r"[*?]+", normalized_query) if len(part) >= 3]
|
| 290 |
if fixed_parts:
|
| 291 |
match_query = " AND ".join(f'"{part.replace(chr(34), chr(34) * 2)}"' for part in fixed_parts)
|
| 292 |
-
|
| 293 |
int(row[0]) for row in connection.execute(
|
| 294 |
"SELECT rowid FROM content_fts WHERE content_fts MATCH ?", (match_query,)
|
| 295 |
)
|
| 296 |
}
|
| 297 |
-
|
|
|
|
| 298 |
candidates = {
|
| 299 |
int(row[0]) for row in connection.execute("SELECT rowid FROM content_fts")
|
| 300 |
}
|
|
@@ -581,13 +592,19 @@ fulltext_databases: Optional[FulltextDatabases] = None
|
|
| 581 |
def tokenize(text: str) -> list[str]:
|
| 582 |
return query_terms(text)
|
| 583 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
def matches_exact_query(text: str, query: str) -> bool:
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
if "*" in query or "?" in query:
|
| 588 |
-
pattern = re.escape(query).replace(r"\*", ".*?").replace(r"\?", ".")
|
| 589 |
-
return re.search(pattern, text, re.DOTALL) is not None
|
| 590 |
-
return query in text
|
| 591 |
|
| 592 |
def has_wildcard_query(query: str) -> bool:
|
| 593 |
return "*" in query or "?" in query
|
|
@@ -887,11 +904,20 @@ def metadata_matches(q: str, exact: bool, search_paths: bool) -> set[int]:
|
|
| 887 |
field = "_search_text" if search_paths else "_file_search_text"
|
| 888 |
if exact:
|
| 889 |
terms = query_terms(q)
|
|
|
|
| 890 |
# Whole-token postings are not a lossless prefilter for Latin
|
| 891 |
# substrings or punctuation-only queries. CJK literals necessarily
|
| 892 |
# contain all of their indexed unigram/bigram tokens, so retain the
|
| 893 |
# fast candidate intersection for that common path.
|
| 894 |
-
if has_wildcard_query(q)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 895 |
candidates = range(len(records))
|
| 896 |
else:
|
| 897 |
candidate_set = None
|
|
@@ -901,7 +927,10 @@ def metadata_matches(q: str, exact: bool, search_paths: bool) -> set[int]:
|
|
| 901 |
if not candidate_set:
|
| 902 |
return set()
|
| 903 |
candidates = candidate_set or set()
|
| 904 |
-
return {
|
|
|
|
|
|
|
|
|
|
| 905 |
terms = query_terms(q)
|
| 906 |
if not terms:
|
| 907 |
return set()
|
|
|
|
| 98 |
if not TOKEN_RE.fullmatch(normalized[index + 1])
|
| 99 |
))
|
| 100 |
|
| 101 |
+
def wildcard_required_tokens(text: str) -> list[str]:
|
| 102 |
+
tokens = []
|
| 103 |
+
for fixed_part in re.split(r"[*?]+", normalize_text(text)):
|
| 104 |
+
for term in TOKEN_RE.findall(fixed_part):
|
| 105 |
+
if re.fullmatch(r"[\u4e00-\u9fff\u3400-\u4dbf]+", term):
|
| 106 |
+
tokens.extend(query_tokens(term))
|
| 107 |
+
return list(dict.fromkeys(tokens))
|
| 108 |
+
|
| 109 |
def decode_doc_numbers(payload: bytes, candidates: set[int] | None = None) -> set[int]:
|
| 110 |
results = set()
|
| 111 |
number = shift = previous = 0
|
|
|
|
| 294 |
normalized_query = normalize_text(query)
|
| 295 |
try:
|
| 296 |
if has_wildcard_query(normalized_query):
|
| 297 |
+
required_tokens = wildcard_required_tokens(normalized_query) if version == TOKENIZER_VERSION else []
|
| 298 |
+
candidates = self._search_postings(connection, has_doc_counts, required_tokens) if required_tokens else None
|
| 299 |
fixed_parts = [part for part in re.split(r"[*?]+", normalized_query) if len(part) >= 3]
|
| 300 |
if fixed_parts:
|
| 301 |
match_query = " AND ".join(f'"{part.replace(chr(34), chr(34) * 2)}"' for part in fixed_parts)
|
| 302 |
+
fts_candidates = {
|
| 303 |
int(row[0]) for row in connection.execute(
|
| 304 |
"SELECT rowid FROM content_fts WHERE content_fts MATCH ?", (match_query,)
|
| 305 |
)
|
| 306 |
}
|
| 307 |
+
candidates = fts_candidates if candidates is None else candidates & fts_candidates
|
| 308 |
+
elif candidates is None:
|
| 309 |
candidates = {
|
| 310 |
int(row[0]) for row in connection.execute("SELECT rowid FROM content_fts")
|
| 311 |
}
|
|
|
|
| 592 |
def tokenize(text: str) -> list[str]:
|
| 593 |
return query_terms(text)
|
| 594 |
|
| 595 |
+
def compile_exact_query(query: str) -> tuple[str, re.Pattern | None]:
|
| 596 |
+
normalized_query = normalize_text(query)
|
| 597 |
+
if has_wildcard_query(normalized_query):
|
| 598 |
+
expression = re.escape(normalized_query).replace(r"\*", ".*?").replace(r"\?", ".")
|
| 599 |
+
return normalized_query, re.compile(expression, re.DOTALL)
|
| 600 |
+
return normalized_query, None
|
| 601 |
+
|
| 602 |
+
def matches_normalized_exact_query(text: str, normalized_query: str, pattern: re.Pattern | None = None) -> bool:
|
| 603 |
+
return pattern.search(text) is not None if pattern is not None else normalized_query in text
|
| 604 |
+
|
| 605 |
def matches_exact_query(text: str, query: str) -> bool:
|
| 606 |
+
normalized_query, pattern = compile_exact_query(query)
|
| 607 |
+
return matches_normalized_exact_query(normalize_text(text), normalized_query, pattern)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 608 |
|
| 609 |
def has_wildcard_query(query: str) -> bool:
|
| 610 |
return "*" in query or "?" in query
|
|
|
|
| 904 |
field = "_search_text" if search_paths else "_file_search_text"
|
| 905 |
if exact:
|
| 906 |
terms = query_terms(q)
|
| 907 |
+
normalized_query, pattern = compile_exact_query(q)
|
| 908 |
# Whole-token postings are not a lossless prefilter for Latin
|
| 909 |
# substrings or punctuation-only queries. CJK literals necessarily
|
| 910 |
# contain all of their indexed unigram/bigram tokens, so retain the
|
| 911 |
# fast candidate intersection for that common path.
|
| 912 |
+
if has_wildcard_query(q):
|
| 913 |
+
candidate_set = None
|
| 914 |
+
for token in wildcard_required_tokens(normalized_query):
|
| 915 |
+
token_indices = word_index.get(token, set())
|
| 916 |
+
candidate_set = set(token_indices) if candidate_set is None else candidate_set & token_indices
|
| 917 |
+
if not candidate_set:
|
| 918 |
+
return set()
|
| 919 |
+
candidates = candidate_set if candidate_set is not None else range(len(records))
|
| 920 |
+
elif not terms or any(re.fullmatch(r"[a-z0-9]+", term) for term in terms):
|
| 921 |
candidates = range(len(records))
|
| 922 |
else:
|
| 923 |
candidate_set = None
|
|
|
|
| 927 |
if not candidate_set:
|
| 928 |
return set()
|
| 929 |
candidates = candidate_set or set()
|
| 930 |
+
return {
|
| 931 |
+
idx for idx in candidates
|
| 932 |
+
if matches_normalized_exact_query(records[idx][field], normalized_query, pattern)
|
| 933 |
+
}
|
| 934 |
terms = query_terms(q)
|
| 935 |
if not terms:
|
| 936 |
return set()
|