vomebook commited on
Commit
b48d8da
·
verified ·
1 Parent(s): 34eadaa

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +2 -6
  2. app.py +40 -14
  3. build_fulltext_db.py +79 -41
  4. start.sh +7 -0
Dockerfile CHANGED
@@ -6,7 +6,7 @@ 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
 
@@ -16,10 +16,6 @@ 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"]
 
6
 
7
  RUN pip install --no-cache-dir -r requirements.txt
8
 
9
+ COPY app.py build_fulltext_db.py start.sh ./
10
 
11
  COPY static/ static/
12
 
 
16
 
17
  COPY CW/ CW/
18
 
 
 
 
 
19
  EXPOSE 7860
20
 
21
+ CMD ["sh", "/app/start.sh"]
app.py CHANGED
@@ -5,6 +5,7 @@ import json
5
  import random
6
  import re
7
  import sqlite3
 
8
  import time
9
  import unicodedata
10
  from functools import lru_cache
@@ -91,39 +92,63 @@ def decode_doc_numbers(payload: bytes, candidates: set[int] | None = None) -> se
91
  class FulltextDatabases:
92
 
93
  def __init__(self, directory: Path):
 
 
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)
101
- self.connections[path.stem] = connection
 
 
 
 
 
 
 
 
 
 
 
 
102
  columns = {row[1] for row in connection.execute("PRAGMA table_info(postings)")}
103
- self.has_doc_counts[path.stem] = "doc_count" in columns
104
  has_metadata = connection.execute(
105
  "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'metadata'"
106
  ).fetchone()
107
  version_row = connection.execute(
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():
117
- connection.close()
 
118
 
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}"',),
@@ -135,7 +160,7 @@ class FulltextDatabases:
135
  tokens = [*query_tokens(query), *literal_query_tokens(query)]
136
  else:
137
  tokens = query_tokens(query) if version == TOKENIZER_VERSION else query_terms(query)
138
- if connection is None or not tokens:
139
  return set()
140
  matched = None
141
  token_hashes = [hashlib.sha256(token.encode("utf-8")).digest()[:16] for token in tokens]
@@ -173,6 +198,7 @@ class FulltextDatabases:
173
  )
174
 
175
  def summaries(self, doc_ids: list[str]) -> dict[str, str]:
 
176
  grouped = {}
177
  for doc_id in doc_ids:
178
  grouped.setdefault(doc_id.split(":", 1)[0], []).append(doc_id)
 
5
  import random
6
  import re
7
  import sqlite3
8
+ import threading
9
  import time
10
  import unicodedata
11
  from functools import lru_cache
 
92
  class FulltextDatabases:
93
 
94
  def __init__(self, directory: Path):
95
+ self.directory = directory
96
+ self.lock = threading.RLock()
97
  self.connections = {}
98
  self.has_doc_counts = {}
99
  self.tokenizer_versions = {}
100
  self.has_content_fts = {}
101
+ self.refresh()
102
+
103
+ def refresh(self) -> None:
104
+ with self.lock:
105
+ for path in self.directory.glob("*.sqlite3"):
106
+ if path.stem in self.connections:
107
+ continue
108
+ try:
109
+ self._open(path)
110
+ except sqlite3.Error as exc:
111
+ print(f"fulltext_open_failed={path.name}:{exc}")
112
+
113
+ def _open(self, path: Path) -> None:
114
+ uri = f"file:{path.resolve()}?mode=ro&immutable=1"
115
+ connection = sqlite3.connect(uri, uri=True, check_same_thread=False)
116
+ try:
117
  columns = {row[1] for row in connection.execute("PRAGMA table_info(postings)")}
 
118
  has_metadata = connection.execute(
119
  "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'metadata'"
120
  ).fetchone()
121
  version_row = connection.execute(
122
  "SELECT value FROM metadata WHERE key = 'tokenizer_version'"
123
  ).fetchone() if has_metadata else None
124
+ has_content_fts = bool(connection.execute(
 
125
  "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'content_fts'"
126
  ).fetchone())
127
+ except Exception:
128
+ connection.close()
129
+ raise
130
+ self.connections[path.stem] = connection
131
+ self.has_doc_counts[path.stem] = "doc_count" in columns
132
+ self.tokenizer_versions[path.stem] = version_row[0] if version_row else "legacy"
133
+ self.has_content_fts[path.stem] = has_content_fts
134
 
135
  def close(self) -> None:
136
+ with self.lock:
137
+ for connection in self.connections.values():
138
+ connection.close()
139
 
140
  def search_source(self, source: str, query: str, exact: bool = False) -> set[int]:
141
+ with self.lock:
142
+ connection = self.connections.get(source)
143
+ if connection is None:
144
+ self.refresh()
145
+ connection = self.connections.get(source)
146
+ if connection is None:
147
+ return set()
148
  version = self.tokenizer_versions.get(source)
149
  if exact and self.has_content_fts.get(source) and "*" not in query and "?" not in query and len(normalize_text(query)) >= 3:
150
+ phrase = normalize_text(query).replace('"', '""')
 
151
  try:
 
152
  rows = connection.execute(
153
  "SELECT rowid FROM content_fts WHERE content_fts MATCH ?",
154
  (f'"{phrase}"',),
 
160
  tokens = [*query_tokens(query), *literal_query_tokens(query)]
161
  else:
162
  tokens = query_tokens(query) if version == TOKENIZER_VERSION else query_terms(query)
163
+ if not tokens:
164
  return set()
165
  matched = None
166
  token_hashes = [hashlib.sha256(token.encode("utf-8")).digest()[:16] for token in tokens]
 
198
  )
199
 
200
  def summaries(self, doc_ids: list[str]) -> dict[str, str]:
201
+ self.refresh()
202
  grouped = {}
203
  for doc_id in doc_ids:
204
  grouped.setdefault(doc_id.split(":", 1)[0], []).append(doc_id)
build_fulltext_db.py CHANGED
@@ -3,8 +3,9 @@ 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
@@ -15,6 +16,7 @@ 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"))
@@ -68,55 +70,91 @@ def build_summary(text: str) -> str:
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:
 
3
  import hashlib
4
  import json
5
  import re
6
+ import shutil
7
  import sqlite3
8
+ import struct
9
  import unicodedata
10
  from collections import defaultdict
11
  from pathlib import Path
 
16
  SUMMARY_CHARS = 700
17
  TOKENIZER_VERSION = "cjk-bigram-boundary-fts5-v5"
18
  LITERAL_PREFIX = "\0literal:"
19
+ POSTING_BUCKETS = 256
20
 
21
  def load_json_gz(path: Path):
22
  return json.loads(gzip.decompress(path.read_bytes()).decode("utf-8"))
 
70
  def fts_content(text: str) -> str:
71
  return unicodedata.normalize("NFKC", text or "").casefold().replace("\0", " ")
72
 
73
+ def database_ready(path: Path, expected_documents: int) -> bool:
74
+ if not path.exists():
75
+ return False
76
+ try:
77
+ uri = f"file:{path.resolve()}?mode=ro&immutable=1"
78
+ with sqlite3.connect(uri, uri=True) as connection:
79
+ version = connection.execute("SELECT value FROM metadata WHERE key = 'tokenizer_version'").fetchone()
80
+ documents = int(connection.execute("SELECT COUNT(*) FROM documents").fetchone()[0])
81
+ fts_rows = int(connection.execute("SELECT COUNT(*) FROM content_fts").fetchone()[0])
82
+ return bool(version and version[0] == TOKENIZER_VERSION and documents == expected_documents and fts_rows == expected_documents)
83
+ except Exception:
84
+ return False
85
+
86
  def build_source_db(source: str, source_records: list[dict]) -> None:
87
  db_path = FULLTEXT_DIR / f"{source}.sqlite3"
88
+ if database_ready(db_path, len(source_records)):
89
+ print(f"{source}: current index already ready", flush=True)
90
+ return
91
  tmp_path = db_path.with_suffix(".sqlite3.tmp")
92
+ bucket_dir = FULLTEXT_DIR / f".{source}-postings"
 
 
93
  missing = 0
94
  if tmp_path.exists():
95
  tmp_path.unlink()
96
+ shutil.rmtree(bucket_dir, ignore_errors=True)
97
+ bucket_dir.mkdir(parents=True)
98
+ bucket_files = [(bucket_dir / f"{index:02x}.bin").open("wb") for index in range(POSTING_BUCKETS)]
99
+ document_count = 0
100
+ posting_count = 0
101
+ try:
102
+ with sqlite3.connect(tmp_path) as connection:
103
+ connection.execute("PRAGMA journal_mode = OFF")
104
+ connection.execute("PRAGMA synchronous = OFF")
105
+ connection.execute("PRAGMA temp_store = FILE")
106
+ connection.execute("CREATE TABLE documents (doc_id TEXT PRIMARY KEY, summary TEXT NOT NULL)")
107
+ connection.execute("CREATE TABLE postings (token_hash BLOB PRIMARY KEY, docs BLOB NOT NULL, doc_count INTEGER NOT NULL)")
108
+ connection.execute("CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
109
+ connection.execute("INSERT INTO metadata (key, value) VALUES ('tokenizer_version', ?)", (TOKENIZER_VERSION,))
110
+ connection.execute("CREATE VIRTUAL TABLE content_fts USING fts5(content, tokenize='trigram', detail='full')")
111
+ for index, record in enumerate(source_records, 1):
112
+ doc_id = record["doc_id"]
113
+ file_path = get_doc_storage_path(record)
114
+ if not file_path.exists():
115
+ missing += 1
116
+ continue
117
+ text = file_path.read_text(encoding="utf-8", errors="ignore")
118
+ number = doc_number(doc_id)
119
+ connection.execute("INSERT INTO documents (doc_id, summary) VALUES (?, ?)", (doc_id, build_summary(text)))
120
+ connection.execute("INSERT INTO content_fts (rowid, content) VALUES (?, ?)", (number, fts_content(text)))
121
+ document_count += 1
122
+ for token in index_tokens(text) | literal_tokens(text):
123
+ token_hash = hashlib.sha256(token.encode("utf-8")).digest()[:16]
124
+ bucket_files[token_hash[0]].write(token_hash + struct.pack(">I", number))
125
+ if index % 100 == 0:
126
+ connection.commit()
127
+ if index % 1000 == 0:
128
+ print(f"{source}: indexed {index}/{len(source_records)} records", flush=True)
129
+ connection.commit()
130
+ for bucket_file in bucket_files:
131
+ bucket_file.close()
132
+ for bucket_index in range(POSTING_BUCKETS):
133
+ grouped: dict[bytes, list[int]] = defaultdict(list)
134
+ with (bucket_dir / f"{bucket_index:02x}.bin").open("rb") as source_file:
135
+ while chunk := source_file.read(20):
136
+ if len(chunk) != 20:
137
+ raise RuntimeError("corrupt posting bucket")
138
+ grouped[chunk[:16]].append(struct.unpack(">I", chunk[16:])[0])
139
+ connection.executemany(
140
+ "INSERT INTO postings (token_hash, docs, doc_count) VALUES (?, ?, ?)",
141
+ ((token_hash, encode_doc_ids(numbers), len(numbers)) for token_hash, numbers in grouped.items()),
142
+ )
143
+ posting_count += len(grouped)
144
+ if bucket_index % 16 == 15:
145
+ connection.commit()
146
+ print(f"{source}: merged posting buckets {bucket_index + 1}/{POSTING_BUCKETS}", flush=True)
147
+ connection.execute("CREATE INDEX idx_documents_doc_id ON documents(doc_id)")
148
+ finally:
149
+ for bucket_file in bucket_files:
150
+ if not bucket_file.closed:
151
+ bucket_file.close()
152
+ shutil.rmtree(bucket_dir, ignore_errors=True)
153
  tmp_path.replace(db_path)
154
  print(
155
  f"{source}: wrote {db_path.relative_to(BASE_DIR)} "
156
+ f"with {document_count} documents, {posting_count} tokens, {missing} missing files",
157
+ flush=True,
158
  )
159
 
160
  def main() -> None:
start.sh ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ mkdir -p /app/data/fulltext
5
+ python /app/build_fulltext_db.py &
6
+
7
+ exec uvicorn app:app --host 0.0.0.0 --port 7860 --log-level info