playlist-admin 2.7.15 → 2.15.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -51
- package/cli/index.js +128 -130
- package/package.json +34 -37
- package/rag/README.md +47 -47
- package/rag/_resolve.py +65 -36
- package/rag/build_db.py +301 -215
- package/rag/query.py +216 -202
- package/rag/srt_to_text.py +49 -49
package/rag/_resolve.py
CHANGED
|
@@ -1,37 +1,66 @@
|
|
|
1
|
-
"""Resolve podcast RAG paths from the app config (same as the CLI/GUI)."""
|
|
2
|
-
import json
|
|
3
|
-
import os
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
1
|
+
"""Resolve podcast RAG paths from the app config (same as the CLI/GUI)."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _is_wsl() -> bool:
|
|
9
|
+
"""偵測是否在 WSL 中執行。"""
|
|
10
|
+
if "microsoft" in platform.release().lower():
|
|
11
|
+
return True
|
|
12
|
+
if os.path.exists("/proc/sys/fs/binfmt_misc/WSLInterop"):
|
|
13
|
+
return True
|
|
14
|
+
return False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _win_to_wsl(path: Path) -> Path:
|
|
18
|
+
"""將 Windows 路徑轉為 WSL /mnt/c/... 路徑。"""
|
|
19
|
+
s = str(path)
|
|
20
|
+
if len(s) >= 2 and s[1] == ":":
|
|
21
|
+
drive = s[0].lower()
|
|
22
|
+
rest = s[2:].replace("\\", "/")
|
|
23
|
+
return Path(f"/mnt/{drive}{rest}")
|
|
24
|
+
return path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_base() -> Path:
|
|
28
|
+
env_bp = os.environ.get("BASE_PATH")
|
|
29
|
+
if env_bp:
|
|
30
|
+
bp = Path(env_bp)
|
|
31
|
+
if _is_wsl():
|
|
32
|
+
bp = _win_to_wsl(bp)
|
|
33
|
+
return bp
|
|
34
|
+
|
|
35
|
+
# WSL: 直接用 /mnt/c/ 路徑
|
|
36
|
+
if _is_wsl():
|
|
37
|
+
return Path("/mnt/c/Users/CPXru/Music/playlist-admin")
|
|
38
|
+
|
|
39
|
+
# Windows: 讀 config.json
|
|
40
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
41
|
+
pointer = None
|
|
42
|
+
if local:
|
|
43
|
+
pointer = Path(local) / "playlist-admin" / "data" / "config.json"
|
|
44
|
+
if not pointer.exists():
|
|
45
|
+
pointer = Path(local) / "Playlist Administrator" / "data" / "config.json"
|
|
46
|
+
try:
|
|
47
|
+
if pointer and pointer.exists():
|
|
48
|
+
data = json.loads(pointer.read_text(encoding="utf-8"))
|
|
49
|
+
bp = data.get("base_path") or data.get("basePath")
|
|
50
|
+
if bp:
|
|
51
|
+
return Path(bp)
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
return Path(r"C:\Users\CPXru\Music\playlist-admin")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def podcast_downloads_dir() -> Path:
|
|
58
|
+
return find_base() / "podcasts"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def rag_data_dir() -> Path:
|
|
62
|
+
return find_base() / "cache" / "podcast"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def chroma_db_dir() -> Path:
|
|
37
66
|
return rag_data_dir() / "chroma_db"
|
package/rag/build_db.py
CHANGED
|
@@ -1,216 +1,302 @@
|
|
|
1
|
-
"""把 podcast 逐字稿 (.txt) 建立成 ChromaDB 向量資料庫
|
|
2
|
-
用法: python rag/build_db.py [--data DIR] [--db DIR] [--model MODEL] [--reset] [--workers N]
|
|
3
|
-
"""
|
|
4
|
-
import argparse
|
|
5
|
-
import os
|
|
6
|
-
import re
|
|
7
|
-
import sys
|
|
8
|
-
import hashlib
|
|
9
|
-
import time
|
|
10
|
-
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
|
|
13
|
-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
14
|
-
|
|
15
|
-
import chromadb
|
|
16
|
-
import requests
|
|
17
|
-
|
|
18
|
-
from _resolve import podcast_downloads_dir, chroma_db_dir
|
|
19
|
-
|
|
20
|
-
if hasattr(sys.stdout, "reconfigure"):
|
|
21
|
-
sys.stdout.reconfigure(encoding="utf-8")
|
|
22
|
-
|
|
23
|
-
OLLAMA_URL = "http://localhost:11434"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def embed_one_batch(model: str, texts: list[str]) -> tuple[list[str], list[list[float]]]:
|
|
27
|
-
"""單批 embedding;400 時逐個重試,回傳 (成功文字, 對應向量)"""
|
|
28
|
-
resp = requests.post(
|
|
29
|
-
f"{OLLAMA_URL}/api/embed",
|
|
30
|
-
json={"model": model, "input": texts},
|
|
31
|
-
timeout=600,
|
|
32
|
-
)
|
|
33
|
-
if resp.status_code == 400:
|
|
34
|
-
kept: list[str] = []
|
|
35
|
-
vectors: list[list[float]] = []
|
|
36
|
-
for t in texts:
|
|
37
|
-
r2 = requests.post(
|
|
38
|
-
f"{OLLAMA_URL}/api/embed",
|
|
39
|
-
json={"model": model, "input": [t]},
|
|
40
|
-
timeout=600,
|
|
41
|
-
)
|
|
42
|
-
if r2.status_code == 400:
|
|
43
|
-
print(f"!! 跳過問題 chunk (len={len(t)}): {t[:100]!r}")
|
|
44
|
-
continue
|
|
45
|
-
r2.raise_for_status()
|
|
46
|
-
kept.append(t)
|
|
47
|
-
vectors.append(r2.json()["embeddings"][0])
|
|
48
|
-
return kept, vectors
|
|
49
|
-
resp.raise_for_status()
|
|
50
|
-
return texts, resp.json()["embeddings"]
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def embed_texts(model: str, texts: list[str], batch: int = 32, workers: int = 6) -> tuple[list[str], list[list[float]]]:
|
|
54
|
-
"""併發呼叫 Ollama /api/embed;回傳 (成功文字, 對應向量)"""
|
|
55
|
-
batches = [texts[i:i + batch] for i in range(0, len(texts), batch)]
|
|
56
|
-
if len(batches) <= 1:
|
|
57
|
-
if not batches:
|
|
58
|
-
return [], []
|
|
59
|
-
return embed_one_batch(model, batches[0])
|
|
60
|
-
|
|
61
|
-
kept_all: list[str] = []
|
|
62
|
-
vectors: list[list[float]] = []
|
|
63
|
-
results: list[tuple[list[str], list[list[float]]]] = [None] * len(batches)
|
|
64
|
-
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
65
|
-
fut = {ex.submit(embed_one_batch, model, b): i for i, b in enumerate(batches)}
|
|
66
|
-
for f in as_completed(fut):
|
|
67
|
-
results[fut[f]] = f.result()
|
|
68
|
-
for k, v in results:
|
|
69
|
-
if k is None:
|
|
70
|
-
continue
|
|
71
|
-
kept_all.extend(k)
|
|
72
|
-
vectors.extend(v)
|
|
73
|
-
return kept_all, vectors
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def read_text_file(path: Path) -> str:
|
|
77
|
-
"""讀檔,自動嘗試 UTF-8 / Big5 / GBK"""
|
|
78
|
-
raw = path.read_bytes()
|
|
79
|
-
for enc in ("utf-8-sig", "utf-8", "big5", "gb18030"):
|
|
80
|
-
try:
|
|
81
|
-
return raw.decode(enc)
|
|
82
|
-
except UnicodeDecodeError:
|
|
83
|
-
continue
|
|
84
|
-
return raw.decode("utf-8", errors="replace")
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def split_sentences(text: str) -> list[str]:
|
|
88
|
-
"""依中文句號斷句,過濾空白行"""
|
|
89
|
-
text = re.sub(r"\s+", "", text)
|
|
90
|
-
parts = re.split(r"(?<=[。!?!?;;])", text)
|
|
91
|
-
return [p for p in parts if len(p) >= 4]
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def make_chunks(sentences: list[str], max_len: int = 600, overlap: int = 60) -> list[str]:
|
|
95
|
-
"""句子接成 chunk,每塊約 max_len 字元,前後重疊 overlap;超長句強制硬切"""
|
|
96
|
-
chunks: list[str] = []
|
|
97
|
-
cur = ""
|
|
98
|
-
for s in sentences:
|
|
99
|
-
while len(s) > max_len:
|
|
100
|
-
if cur:
|
|
101
|
-
chunks.append(cur)
|
|
102
|
-
cur = cur[-overlap:] if len(cur) > overlap else cur
|
|
103
|
-
chunks.append(s[:max_len])
|
|
104
|
-
s = s[max_len:]
|
|
105
|
-
if len(cur) + len(s) > max_len and cur:
|
|
106
|
-
chunks.append(cur)
|
|
107
|
-
tail = cur[-overlap:] if len(cur) > overlap else cur
|
|
108
|
-
cur = tail + s
|
|
109
|
-
else:
|
|
110
|
-
cur += s
|
|
111
|
-
if cur:
|
|
112
|
-
chunks.append(cur)
|
|
113
|
-
return chunks
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def detect_meta(path: Path) -> dict:
|
|
117
|
-
"""
|
|
118
|
-
parts = path.parts
|
|
119
|
-
show = parts[-2] if len(parts) >= 2 else "unknown"
|
|
120
|
-
name = path.stem
|
|
121
|
-
m = re.search(r"(20\d{2})[_\-_](\d{1,2})[_\-_](\d{1,2})", name)
|
|
122
|
-
date = f"{m.group(1)}-{m.group(2).zfill(2)}-{m.group(3).zfill(2)}" if m else ""
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
ap
|
|
129
|
-
ap.add_argument("--
|
|
130
|
-
ap.add_argument("--
|
|
131
|
-
ap.add_argument("--
|
|
132
|
-
ap.add_argument("--
|
|
133
|
-
ap.add_argument("--
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
1
|
+
"""把 podcast 逐字稿 (.txt) 建立成 ChromaDB 向量資料庫
|
|
2
|
+
用法: python rag/build_db.py [--data DIR] [--db DIR] [--model MODEL] [--reset] [--workers N]
|
|
3
|
+
"""
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import hashlib
|
|
9
|
+
import time
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
14
|
+
|
|
15
|
+
import chromadb
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from _resolve import podcast_downloads_dir, chroma_db_dir
|
|
19
|
+
|
|
20
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
21
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
22
|
+
|
|
23
|
+
OLLAMA_URL = "http://localhost:11434"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def embed_one_batch(model: str, texts: list[str]) -> tuple[list[str], list[list[float]]]:
|
|
27
|
+
"""單批 embedding;400 時逐個重試,回傳 (成功文字, 對應向量)"""
|
|
28
|
+
resp = requests.post(
|
|
29
|
+
f"{OLLAMA_URL}/api/embed",
|
|
30
|
+
json={"model": model, "input": texts},
|
|
31
|
+
timeout=600,
|
|
32
|
+
)
|
|
33
|
+
if resp.status_code == 400:
|
|
34
|
+
kept: list[str] = []
|
|
35
|
+
vectors: list[list[float]] = []
|
|
36
|
+
for t in texts:
|
|
37
|
+
r2 = requests.post(
|
|
38
|
+
f"{OLLAMA_URL}/api/embed",
|
|
39
|
+
json={"model": model, "input": [t]},
|
|
40
|
+
timeout=600,
|
|
41
|
+
)
|
|
42
|
+
if r2.status_code == 400:
|
|
43
|
+
print(f"!! 跳過問題 chunk (len={len(t)}): {t[:100]!r}")
|
|
44
|
+
continue
|
|
45
|
+
r2.raise_for_status()
|
|
46
|
+
kept.append(t)
|
|
47
|
+
vectors.append(r2.json()["embeddings"][0])
|
|
48
|
+
return kept, vectors
|
|
49
|
+
resp.raise_for_status()
|
|
50
|
+
return texts, resp.json()["embeddings"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def embed_texts(model: str, texts: list[str], batch: int = 32, workers: int = 6) -> tuple[list[str], list[list[float]]]:
|
|
54
|
+
"""併發呼叫 Ollama /api/embed;回傳 (成功文字, 對應向量)"""
|
|
55
|
+
batches = [texts[i:i + batch] for i in range(0, len(texts), batch)]
|
|
56
|
+
if len(batches) <= 1:
|
|
57
|
+
if not batches:
|
|
58
|
+
return [], []
|
|
59
|
+
return embed_one_batch(model, batches[0])
|
|
60
|
+
|
|
61
|
+
kept_all: list[str] = []
|
|
62
|
+
vectors: list[list[float]] = []
|
|
63
|
+
results: list[tuple[list[str], list[list[float]]]] = [None] * len(batches)
|
|
64
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
65
|
+
fut = {ex.submit(embed_one_batch, model, b): i for i, b in enumerate(batches)}
|
|
66
|
+
for f in as_completed(fut):
|
|
67
|
+
results[fut[f]] = f.result()
|
|
68
|
+
for k, v in results:
|
|
69
|
+
if k is None:
|
|
70
|
+
continue
|
|
71
|
+
kept_all.extend(k)
|
|
72
|
+
vectors.extend(v)
|
|
73
|
+
return kept_all, vectors
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def read_text_file(path: Path) -> str:
|
|
77
|
+
"""讀檔,自動嘗試 UTF-8 / Big5 / GBK"""
|
|
78
|
+
raw = path.read_bytes()
|
|
79
|
+
for enc in ("utf-8-sig", "utf-8", "big5", "gb18030"):
|
|
80
|
+
try:
|
|
81
|
+
return raw.decode(enc)
|
|
82
|
+
except UnicodeDecodeError:
|
|
83
|
+
continue
|
|
84
|
+
return raw.decode("utf-8", errors="replace")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def split_sentences(text: str) -> list[str]:
|
|
88
|
+
"""依中文句號斷句,過濾空白行"""
|
|
89
|
+
text = re.sub(r"\s+", "", text)
|
|
90
|
+
parts = re.split(r"(?<=[。!?!?;;])", text)
|
|
91
|
+
return [p for p in parts if len(p) >= 4]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def make_chunks(sentences: list[str], max_len: int = 600, overlap: int = 60) -> list[str]:
|
|
95
|
+
"""句子接成 chunk,每塊約 max_len 字元,前後重疊 overlap;超長句強制硬切"""
|
|
96
|
+
chunks: list[str] = []
|
|
97
|
+
cur = ""
|
|
98
|
+
for s in sentences:
|
|
99
|
+
while len(s) > max_len:
|
|
100
|
+
if cur:
|
|
101
|
+
chunks.append(cur)
|
|
102
|
+
cur = cur[-overlap:] if len(cur) > overlap else cur
|
|
103
|
+
chunks.append(s[:max_len])
|
|
104
|
+
s = s[max_len:]
|
|
105
|
+
if len(cur) + len(s) > max_len and cur:
|
|
106
|
+
chunks.append(cur)
|
|
107
|
+
tail = cur[-overlap:] if len(cur) > overlap else cur
|
|
108
|
+
cur = tail + s
|
|
109
|
+
else:
|
|
110
|
+
cur += s
|
|
111
|
+
if cur:
|
|
112
|
+
chunks.append(cur)
|
|
113
|
+
return chunks
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def detect_meta(path: Path) -> dict:
|
|
117
|
+
"""從檔名/路徑猜節目、日期與來源類型"""
|
|
118
|
+
parts = path.parts
|
|
119
|
+
show = parts[-2] if len(parts) >= 2 else "unknown"
|
|
120
|
+
name = path.stem
|
|
121
|
+
m = re.search(r"(20\d{2})[_\-_](\d{1,2})[_\-_](\d{1,2})", name)
|
|
122
|
+
date = f"{m.group(1)}-{m.group(2).zfill(2)}-{m.group(3).zfill(2)}" if m else ""
|
|
123
|
+
source = "course" if re.search(r"夜工智|機械群|課程", str(path)) else "podcast"
|
|
124
|
+
return {"show": show, "file": name, "date": date, "source": source}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main() -> None:
|
|
128
|
+
ap = argparse.ArgumentParser()
|
|
129
|
+
ap.add_argument("--data", default=str(podcast_downloads_dir()))
|
|
130
|
+
ap.add_argument("--db", default=str(chroma_db_dir()))
|
|
131
|
+
ap.add_argument("--model", default="bge-m3")
|
|
132
|
+
ap.add_argument("--reset", action="store_true", help="重建資料庫")
|
|
133
|
+
ap.add_argument("--limit", type=int, default=0, help="只索引前 N 篇 (測試用)")
|
|
134
|
+
ap.add_argument("--workers", type=int, default=6, help="併發 embedding 數")
|
|
135
|
+
ap.add_argument("--batch", type=int, default=64, help="每批 embedding 數量")
|
|
136
|
+
args = ap.parse_args()
|
|
137
|
+
|
|
138
|
+
client = chromadb.PersistentClient(path=args.db)
|
|
139
|
+
if args.reset:
|
|
140
|
+
try:
|
|
141
|
+
client.delete_collection("podcasts")
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
col = client.get_or_create_collection(
|
|
145
|
+
"podcasts",
|
|
146
|
+
metadata={"hnsw:space": "cosine"},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# ── Dedup: remove duplicate sig entries, keep first only ──────
|
|
150
|
+
total = col.count()
|
|
151
|
+
if total > 0:
|
|
152
|
+
sig_to_ids: dict[str, list[str]] = {}
|
|
153
|
+
offset = 0
|
|
154
|
+
while offset < total:
|
|
155
|
+
batch = col.get(limit=5000, offset=offset, include=["metadatas"])
|
|
156
|
+
ids = batch["ids"]
|
|
157
|
+
metas = batch["metadatas"]
|
|
158
|
+
for rid, m in zip(ids, metas):
|
|
159
|
+
sig = (m or {}).get("sig", "")
|
|
160
|
+
if sig:
|
|
161
|
+
sig_to_ids.setdefault(sig, []).append(rid)
|
|
162
|
+
offset += 5000
|
|
163
|
+
to_delete = [ids[1:] for ids in sig_to_ids.values() if len(ids) > 1]
|
|
164
|
+
flat_delete = [item for sublist in to_delete for item in sublist]
|
|
165
|
+
if flat_delete:
|
|
166
|
+
print(f"Dedup: removing {len(flat_delete)} duplicate records...")
|
|
167
|
+
for i in range(0, len(flat_delete), 4500):
|
|
168
|
+
col.delete(ids=flat_delete[i:i+4500])
|
|
169
|
+
print(f"Dedup done. New count: {col.count()}")
|
|
170
|
+
|
|
171
|
+
# 已索引內容 hash 去重 - 分段讀取避免 SQL 變數超限
|
|
172
|
+
done_sigs: set[str] = set()
|
|
173
|
+
offset = 0
|
|
174
|
+
while True:
|
|
175
|
+
batch = col.get(limit=5000, offset=offset, include=["metadatas"])["metadatas"]
|
|
176
|
+
if not batch:
|
|
177
|
+
break
|
|
178
|
+
for m in batch:
|
|
179
|
+
if m and m.get("sig"):
|
|
180
|
+
done_sigs.add(m["sig"])
|
|
181
|
+
offset += 5000
|
|
182
|
+
if offset > 1000000:
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
# 一次性遷移: 舊版 chunks 沒有 sig — 從 metadata 的 path 讀檔補上,
|
|
186
|
+
# 避免 1662 篇全部被當新的重嵌入。
|
|
187
|
+
def file_sig(path: Path) -> str:
|
|
188
|
+
raw = path.read_bytes()
|
|
189
|
+
# Strip BOM and normalize whitespace to avoid false re-embeds
|
|
190
|
+
if raw.startswith(b'\xef\xbb\xbf'):
|
|
191
|
+
raw = raw[3:]
|
|
192
|
+
return hashlib.md5(raw).hexdigest()
|
|
193
|
+
|
|
194
|
+
migrated = 0
|
|
195
|
+
offset = 0
|
|
196
|
+
while True:
|
|
197
|
+
batch = col.get(limit=5000, offset=offset,
|
|
198
|
+
include=["metadatas"])["metadatas"]
|
|
199
|
+
if not batch:
|
|
200
|
+
break
|
|
201
|
+
for m in batch:
|
|
202
|
+
if not m or m.get("sig"):
|
|
203
|
+
continue
|
|
204
|
+
p = m.get("path")
|
|
205
|
+
if not p or not os.path.exists(p):
|
|
206
|
+
continue
|
|
207
|
+
try:
|
|
208
|
+
sig = file_sig(Path(p))
|
|
209
|
+
done_sigs.add(sig)
|
|
210
|
+
migrated += 1
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
offset += 5000
|
|
214
|
+
if offset > 1000000:
|
|
215
|
+
break
|
|
216
|
+
if migrated:
|
|
217
|
+
print(f"migrated {migrated} legacy chunks to content-sig dedup")
|
|
218
|
+
|
|
219
|
+
# 無內容/失敗檔案的跳過清單:0 chunks 的檔案不會寫進 DB,
|
|
220
|
+
# 若沒有持久狀態會每次 pipeline 都被當「待索引」重試。
|
|
221
|
+
skip_state_file = Path(args.db) / "_skipped_files.json"
|
|
222
|
+
skipped: set[str] = set()
|
|
223
|
+
if skip_state_file.exists():
|
|
224
|
+
try:
|
|
225
|
+
import json
|
|
226
|
+
skipped = set(json.loads(skip_state_file.read_text(encoding="utf-8")))
|
|
227
|
+
except Exception:
|
|
228
|
+
skipped = set()
|
|
229
|
+
|
|
230
|
+
# 檔案內容 hash — 內容更新(來源切換)時 sig 變 → 重嵌入
|
|
231
|
+
def has_content(path: Path) -> bool:
|
|
232
|
+
return bool(make_chunks(split_sentences(read_text_file(path))))
|
|
233
|
+
|
|
234
|
+
files = [
|
|
235
|
+
f for f in sorted(Path(args.data).rglob("*.txt"))
|
|
236
|
+
if file_sig(f) not in done_sigs
|
|
237
|
+
and not (f.stem in skipped and not has_content(f))
|
|
238
|
+
]
|
|
239
|
+
if args.limit:
|
|
240
|
+
files = files[: args.limit]
|
|
241
|
+
print(f"待索引 {len(files)} 篇 (已嵌入 {len(done_sigs)} 篇, 無內容跳過 {len(skipped)} 篇)")
|
|
242
|
+
|
|
243
|
+
total_chunks = 0
|
|
244
|
+
t_start = time.time()
|
|
245
|
+
for i, f in enumerate(files, 1):
|
|
246
|
+
text = read_text_file(f)
|
|
247
|
+
chunks = make_chunks(split_sentences(text))
|
|
248
|
+
# token 保險: 單 chunk 超過 2800 字元就再切
|
|
249
|
+
final_chunks: list[str] = []
|
|
250
|
+
for ch in chunks:
|
|
251
|
+
final_chunks.extend(
|
|
252
|
+
[ch[i:i + 2800] for i in range(0, len(ch), 2800)] if len(ch) > 2800 else [ch]
|
|
253
|
+
)
|
|
254
|
+
chunks = final_chunks
|
|
255
|
+
if not chunks:
|
|
256
|
+
# 空/太短內容: 標記為已跳過, 避免每次重試
|
|
257
|
+
skipped.add(f.stem)
|
|
258
|
+
import json
|
|
259
|
+
skip_state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
skip_state_file.write_text(
|
|
261
|
+
json.dumps(sorted(skipped), ensure_ascii=False, indent=1),
|
|
262
|
+
encoding="utf-8",
|
|
263
|
+
)
|
|
264
|
+
continue
|
|
265
|
+
meta = detect_meta(f)
|
|
266
|
+
sig = file_sig(f)
|
|
267
|
+
try:
|
|
268
|
+
kept_chunks, vectors = embed_texts(args.model, chunks, batch=args.batch, workers=args.workers)
|
|
269
|
+
except requests.exceptions.HTTPError as e:
|
|
270
|
+
print(f"!! HTTP 錯誤 檔案={meta['file']} 前300字={chunks[0][:300] if chunks else ''}")
|
|
271
|
+
raise
|
|
272
|
+
except requests.exceptions.ConnectionError:
|
|
273
|
+
print("無法連線 Ollama,請先啟動 (ollama serve) 並 pull bge-m3")
|
|
274
|
+
sys.exit(1)
|
|
275
|
+
if not kept_chunks:
|
|
276
|
+
continue
|
|
277
|
+
ids = [
|
|
278
|
+
hashlib.md5(f"{meta['file']}|{j}".encode()).hexdigest()
|
|
279
|
+
for j in range(len(kept_chunks))
|
|
280
|
+
]
|
|
281
|
+
metadatas = [
|
|
282
|
+
{**meta, "sig": sig, "chunk": j, "chars": len(c), "path": str(f)}
|
|
283
|
+
for j, c in enumerate(kept_chunks)
|
|
284
|
+
]
|
|
285
|
+
col.add(ids=ids, documents=kept_chunks, metadatas=metadatas, embeddings=vectors)
|
|
286
|
+
total_chunks += len(kept_chunks)
|
|
287
|
+
if i % 5 == 0 or i == len(files):
|
|
288
|
+
elapsed = time.time() - t_start
|
|
289
|
+
speed = i / elapsed
|
|
290
|
+
eta = (len(files) - i) / speed if speed > 0 else 0
|
|
291
|
+
print(
|
|
292
|
+
f"[{i}/{len(files)}] {i/len(files)*100:.1f}% | "
|
|
293
|
+
f"{speed:.2f}篇/秒 | 剩餘ETA {eta/60:.1f}分 | "
|
|
294
|
+
f"chunks累計 {total_chunks} | {meta['show'][:10]}/{meta['file'][:25]}",
|
|
295
|
+
flush=True,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
print(f"完成! 共 {len(files)} 篇, {total_chunks} 個 chunk")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
216
302
|
main()
|