vomebook commited on
Commit
34eadaa
·
1 Parent(s): 2244d7e

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +10 -1
  2. app.py +31 -3
  3. build_fulltext_db.py +133 -0
Dockerfile CHANGED
@@ -3,14 +3,23 @@ FROM python:3.11-slim
3
  WORKDIR /app
4
 
5
  COPY requirements.txt .
 
6
  RUN pip install --no-cache-dir -r requirements.txt
7
 
8
- COPY app.py .
 
9
  COPY static/ static/
 
10
  COPY data/ data/
 
11
  COPY CCRD/ CCRD/
 
12
  COPY CW/ CW/
13
 
 
 
 
 
14
  EXPOSE 7860
15
 
16
  CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--log-level", "info"]
 
3
  WORKDIR /app
4
 
5
  COPY requirements.txt .
6
+
7
  RUN pip install --no-cache-dir -r requirements.txt
8
 
9
+ COPY app.py build_fulltext_db.py ./
10
+
11
  COPY static/ static/
12
+
13
  COPY data/ data/
14
+
15
  COPY CCRD/ CCRD/
16
+
17
  COPY CW/ CW/
18
 
19
+ # Full-text indexes are derived artifacts. Build them inside the Space image so
20
+ # the repository never needs to store SQLite files larger than the LFS limit.
21
+ RUN python build_fulltext_db.py
22
+
23
  EXPOSE 7860
24
 
25
  CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--log-level", "info"]
app.py CHANGED
@@ -35,7 +35,7 @@ extension_counts: dict[str, int] = {}
35
  source_extension_counts: dict[str, dict[str, int]] = {}
36
  word_index: dict[str, set[int]] = {}
37
  TOKEN_RE = re.compile(r"[a-z0-9]+|[\u4e00-\u9fff\u3400-\u4dbf]+")
38
- TOKENIZER_VERSION = "cjk-bigram-boundary-v4"
39
  LITERAL_PREFIX = "\0literal:"
40
  API_CACHE_TTL_SECONDS = 120
41
  SEARCH_CACHE_TTL_SECONDS = 30
@@ -94,6 +94,7 @@ class FulltextDatabases:
94
  self.connections = {}
95
  self.has_doc_counts = {}
96
  self.tokenizer_versions = {}
 
97
  for path in directory.glob("*.sqlite3"):
98
  uri = f"file:{path.resolve()}?mode=ro&immutable=1"
99
  connection = sqlite3.connect(uri, uri=True, check_same_thread=False)
@@ -107,6 +108,9 @@ class FulltextDatabases:
107
  "SELECT value FROM metadata WHERE key = 'tokenizer_version'"
108
  ).fetchone() if has_metadata else None
109
  self.tokenizer_versions[path.stem] = version_row[0] if version_row else "legacy"
 
 
 
110
 
111
  def close(self) -> None:
112
  for connection in self.connections.values():
@@ -115,6 +119,18 @@ class FulltextDatabases:
115
  def search_source(self, source: str, query: str, exact: bool = False) -> set[int]:
116
  connection = self.connections.get(source)
117
  version = self.tokenizer_versions.get(source)
 
 
 
 
 
 
 
 
 
 
 
 
118
  if exact and version == TOKENIZER_VERSION and "*" not in query and "?" not in query:
119
  tokens = [*query_tokens(query), *literal_query_tokens(query)]
120
  else:
@@ -147,6 +163,15 @@ class FulltextDatabases:
147
  return set()
148
  return matched or set()
149
 
 
 
 
 
 
 
 
 
 
150
  def summaries(self, doc_ids: list[str]) -> dict[str, str]:
151
  grouped = {}
152
  for doc_id in doc_ids:
@@ -392,7 +417,8 @@ def fulltext_search(q="", sources_filter=None, folders=None, min_size=None, max_
392
  matched_doc_ids.update(f"{source_slug}:{doc_number}" for doc_number in fulltext_databases.search_source(source_slug, q, exact))
393
  content_indices = {idx for doc_id in matched_doc_ids if (idx := record_map_index.get(doc_id)) is not None}
394
  indices = set(content_indices)
395
- if exact and ("*" in q or "?" in q or len(normalize_text(q)) > 3):
 
396
  content_indices = verify_fulltext_matches(content_indices, q)
397
  indices = set(content_indices)
398
  indices.update(metadata_matches(q, exact, search_paths))
@@ -409,7 +435,9 @@ def fulltext_search(q="", sources_filter=None, folders=None, min_size=None, max_
409
  result_items = add_summaries(
410
  [trim_record(records[idx]) for idx in filtered[start:start + page_size]],
411
  q,
412
- matched_snippets=True,
 
 
413
  content_indices=content_indices,
414
  )
415
  return {"results": result_items, "total": total, "page": page, "page_size": page_size}
 
35
  source_extension_counts: dict[str, dict[str, int]] = {}
36
  word_index: dict[str, set[int]] = {}
37
  TOKEN_RE = re.compile(r"[a-z0-9]+|[\u4e00-\u9fff\u3400-\u4dbf]+")
38
+ TOKENIZER_VERSION = "cjk-bigram-boundary-fts5-v5"
39
  LITERAL_PREFIX = "\0literal:"
40
  API_CACHE_TTL_SECONDS = 120
41
  SEARCH_CACHE_TTL_SECONDS = 30
 
94
  self.connections = {}
95
  self.has_doc_counts = {}
96
  self.tokenizer_versions = {}
97
+ self.has_content_fts = {}
98
  for path in directory.glob("*.sqlite3"):
99
  uri = f"file:{path.resolve()}?mode=ro&immutable=1"
100
  connection = sqlite3.connect(uri, uri=True, check_same_thread=False)
 
108
  "SELECT value FROM metadata WHERE key = 'tokenizer_version'"
109
  ).fetchone() if has_metadata else None
110
  self.tokenizer_versions[path.stem] = version_row[0] if version_row else "legacy"
111
+ self.has_content_fts[path.stem] = bool(connection.execute(
112
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'content_fts'"
113
+ ).fetchone())
114
 
115
  def close(self) -> None:
116
  for connection in self.connections.values():
 
119
  def search_source(self, source: str, query: str, exact: bool = False) -> set[int]:
120
  connection = self.connections.get(source)
121
  version = self.tokenizer_versions.get(source)
122
+ if exact and self.has_content_fts.get(source) and "*" not in query and "?" not in query and len(normalize_text(query)) >= 3:
123
+ phrase = normalize_text(query)
124
+ grams = [phrase[index:index + 3] for index in range(len(phrase) - 2)]
125
+ try:
126
+ match_query = " AND ".join('"' + gram.replace('"', '""') + '"' for gram in grams)
127
+ rows = connection.execute(
128
+ "SELECT rowid FROM content_fts WHERE content_fts MATCH ?",
129
+ (f'"{phrase}"',),
130
+ ).fetchall()
131
+ return {int(row[0]) for row in rows}
132
+ except sqlite3.OperationalError:
133
+ pass
134
  if exact and version == TOKENIZER_VERSION and "*" not in query and "?" not in query:
135
  tokens = [*query_tokens(query), *literal_query_tokens(query)]
136
  else:
 
163
  return set()
164
  return matched or set()
165
 
166
+ def can_trust_exact_content(self, source: str, query: str) -> bool:
167
+ return (
168
+ self.tokenizer_versions.get(source) == TOKENIZER_VERSION
169
+ and self.has_content_fts.get(source, False)
170
+ and "*" not in query
171
+ and "?" not in query
172
+ and len(normalize_text(query)) >= 3
173
+ )
174
+
175
  def summaries(self, doc_ids: list[str]) -> dict[str, str]:
176
  grouped = {}
177
  for doc_id in doc_ids:
 
417
  matched_doc_ids.update(f"{source_slug}:{doc_number}" for doc_number in fulltext_databases.search_source(source_slug, q, exact))
418
  content_indices = {idx for doc_id in matched_doc_ids if (idx := record_map_index.get(doc_id)) is not None}
419
  indices = set(content_indices)
420
+ trust_fts = exact and all(fulltext_databases.can_trust_exact_content(source_slug, q) for source_slug in source_slugs)
421
+ if exact and not trust_fts and ("*" in q or "?" in q or len(normalize_text(q)) > 3):
422
  content_indices = verify_fulltext_matches(content_indices, q)
423
  indices = set(content_indices)
424
  indices.update(metadata_matches(q, exact, search_paths))
 
435
  result_items = add_summaries(
436
  [trim_record(records[idx]) for idx in filtered[start:start + page_size]],
437
  q,
438
+ # Keep the first page fast. Exact position snippets are available from preview;
439
+ # do not open dozens of large source files just to decorate list results.
440
+ matched_snippets=False,
441
  content_indices=content_indices,
442
  )
443
  return {"results": result_items, "total": total, "page": page, "page_size": page_size}
build_fulltext_db.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import gzip
3
+ import hashlib
4
+ import json
5
+ import re
6
+ import sqlite3
7
+ import unicodedata
8
+ import unicodedata
9
+ from collections import defaultdict
10
+ from pathlib import Path
11
+ BASE_DIR = Path(__file__).resolve().parent
12
+ DATA_PATH = BASE_DIR / "data/search_data.json.gz"
13
+ FULLTEXT_DIR = BASE_DIR / "data/fulltext"
14
+ TOKEN_RE = re.compile(r"[a-z0-9]+|[\u4e00-\u9fff\u3400-\u4dbf]+")
15
+ SUMMARY_CHARS = 700
16
+ TOKENIZER_VERSION = "cjk-bigram-boundary-fts5-v5"
17
+ LITERAL_PREFIX = "\0literal:"
18
+
19
+ def load_json_gz(path: Path):
20
+ return json.loads(gzip.decompress(path.read_bytes()).decode("utf-8"))
21
+
22
+ def index_tokens(text: str) -> set[str]:
23
+ tokens = set()
24
+ normalized = unicodedata.normalize("NFKC", text or "").casefold()
25
+ for part in TOKEN_RE.findall(normalized):
26
+ if re.fullmatch(r"[\u4e00-\u9fff\u3400-\u4dbf]+", part):
27
+ tokens.update(part)
28
+ tokens.update(part[index:index + 2] for index in range(len(part) - 1))
29
+ else:
30
+ tokens.add(part)
31
+ return tokens
32
+
33
+ def literal_tokens(text: str) -> set[str]:
34
+ normalized = normalize_literal(text)
35
+ return {
36
+ LITERAL_PREFIX + normalized[index:index + 3]
37
+ for index in range(len(normalized) - 2)
38
+ if not TOKEN_RE.fullmatch(normalized[index + 1])
39
+ }
40
+
41
+ def normalize_literal(text: str) -> str:
42
+ return unicodedata.normalize("NFKC", text or "").casefold()
43
+
44
+ def encode_doc_ids(doc_numbers: list[int]) -> bytes:
45
+ previous = 0
46
+ output = bytearray()
47
+ for doc_number in sorted(doc_numbers):
48
+ delta = doc_number - previous
49
+ previous = doc_number
50
+ while delta >= 0x80:
51
+ output.append((delta & 0x7F) | 0x80)
52
+ delta >>= 7
53
+ output.append(delta)
54
+ return bytes(output)
55
+
56
+ def doc_number(doc_id: str) -> int:
57
+ try:
58
+ return int(doc_id.rsplit(":", 1)[1])
59
+ except (IndexError, ValueError) as exc:
60
+ raise ValueError(f"doc_id must end with ':<number>': {doc_id}") from exc
61
+
62
+ def get_doc_storage_path(record: dict) -> Path:
63
+ return BASE_DIR / record["storage_root"] / record["storage_rel_path"]
64
+
65
+ def build_summary(text: str) -> str:
66
+ return re.sub(r"\s+", " ", text).strip()[:SUMMARY_CHARS]
67
+
68
+ def fts_content(text: str) -> str:
69
+ return unicodedata.normalize("NFKC", text or "").casefold().replace("\0", " ")
70
+
71
+ def build_source_db(source: str, source_records: list[dict]) -> None:
72
+ db_path = FULLTEXT_DIR / f"{source}.sqlite3"
73
+ tmp_path = db_path.with_suffix(".sqlite3.tmp")
74
+ postings: dict[bytes, set[int]] = defaultdict(set)
75
+ documents = []
76
+ content_rows = []
77
+ missing = 0
78
+ if tmp_path.exists():
79
+ tmp_path.unlink()
80
+ for index, record in enumerate(source_records, 1):
81
+ doc_id = record["doc_id"]
82
+ file_path = get_doc_storage_path(record)
83
+ if not file_path.exists():
84
+ missing += 1
85
+ continue
86
+ text = file_path.read_text(encoding="utf-8", errors="ignore")
87
+ documents.append((doc_id, build_summary(text)))
88
+ number = doc_number(doc_id)
89
+ # FTS5 tokenization stops at embedded NUL bytes even though selecting the
90
+ # virtual column returns the remainder. Replace them only in derived index data.
91
+ content_rows.append((number, fts_content(text)))
92
+ for token in index_tokens(text) | literal_tokens(text):
93
+ token_hash = hashlib.sha256(token.encode("utf-8")).digest()[:16]
94
+ postings[token_hash].add(number)
95
+ if index % 1000 == 0:
96
+ print(f"{source}: indexed {index}/{len(source_records)} records")
97
+ with sqlite3.connect(tmp_path) as connection:
98
+ connection.execute("PRAGMA journal_mode = OFF")
99
+ connection.execute("PRAGMA synchronous = OFF")
100
+ connection.execute("PRAGMA temp_store = MEMORY")
101
+ connection.execute("CREATE TABLE documents (doc_id TEXT PRIMARY KEY, summary TEXT NOT NULL)")
102
+ connection.execute("CREATE TABLE postings (token_hash BLOB PRIMARY KEY, docs BLOB NOT NULL, doc_count INTEGER NOT NULL)")
103
+ connection.execute("CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
104
+ connection.execute("INSERT INTO metadata (key, value) VALUES ('tokenizer_version', ?)", (TOKENIZER_VERSION,))
105
+ connection.execute("CREATE VIRTUAL TABLE content_fts USING fts5(content, tokenize='trigram', detail='full')")
106
+ connection.executemany("INSERT INTO documents (doc_id, summary) VALUES (?, ?)", documents)
107
+ connection.executemany("INSERT INTO content_fts (rowid, content) VALUES (?, ?)", content_rows)
108
+ connection.executemany(
109
+ "INSERT INTO postings (token_hash, docs, doc_count) VALUES (?, ?, ?)",
110
+ (
111
+ (token_hash, encode_doc_ids(list(doc_numbers)), len(doc_numbers))
112
+ for token_hash, doc_numbers in postings.items()
113
+ ),
114
+ )
115
+ connection.execute("CREATE INDEX idx_documents_doc_id ON documents(doc_id)")
116
+ tmp_path.replace(db_path)
117
+ print(
118
+ f"{source}: wrote {db_path.relative_to(BASE_DIR)} "
119
+ f"with {len(documents)} documents, {len(postings)} tokens, {missing} missing files"
120
+ )
121
+
122
+ def main() -> None:
123
+ payload = load_json_gz(DATA_PATH)
124
+ records = payload.get("records", [])
125
+ FULLTEXT_DIR.mkdir(parents=True, exist_ok=True)
126
+ records_by_source: dict[str, list[dict]] = defaultdict(list)
127
+ for record in records:
128
+ records_by_source[record["source"]].append(record)
129
+ for source in sorted(records_by_source):
130
+ build_source_db(source, records_by_source[source])
131
+
132
+ if __name__ == "__main__":
133
+ main()