playlist-admin 2.15.17 → 2.15.19

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/rag/build_db.py CHANGED
@@ -1,302 +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
- 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__":
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__":
302
302
  main()