- docs: 删除 CLAUDE.md / continuation.md / english-news-plan.md 及旧版 intlnews_usage.*,
统一迁移到 docs/{README,architecture,quickstart,usage,pipeline,configuration,deployment,development,faq}.md
- README: 精简为仓库入口,指向 docs/
- configs/sources.yaml: 更新注释指向新文档
- .env.example: 修正 DashScope Embedding 端点说明
Pipeline 修复:
- dedup/llm/embedding/vectorstore/reporter: 过滤 M2 no_content / 空正文,避免污染下游与 Qdrant
- dedup/pipeline: 改为先写唯一文件再写指纹,避免崩溃导致文章永久丢失
- crawler/orchestrator: sources_crawled 改为“尝试数”,成功数 = crawled - failed
- crawler/storage: write_index_jsonl 从文章路径推断日期,修复跨天/测试路径问题
- scheduler/pipeline: STEP_TIMEOUTS 实际生效(SIGALRM)
- scheduler/reporter: emb_count 排除 index.json;日报跳过无原文事件
- vectorstore/pipeline: payload 增加 source_ids;--recreate --all 时空日期也重建 collection
- app/cli: extract/dedup/translate/embed/index/pipeline 支持 --date;embed/index 支持 --all;crawl 全源失败返回非零
- scripts: domestic_full/crawl_8g/crawl_2g/pipeline 安全加载 .env;M1 全失败不标记且最终退出码=1
185 lines
5.6 KiB
Python
185 lines
5.6 KiB
Python
"""批量向量生成管道。
|
||
|
||
输入: data/events/{YYYYMMDD}/{url_hash}.json(M4 翻译+事件输出)
|
||
输出: data/embeddings/{YYYYMMDD}/{url_hash}.json
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
from crawler.utils import get_news_day
|
||
from embedding.client import (
|
||
EmbeddingConfig,
|
||
load_embedding_config,
|
||
make_embedding_client,
|
||
)
|
||
from embedding.embedder import embed_articles
|
||
from embedding.models import EmbeddingError
|
||
from llm.models import EnTranslatedArticle
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _load_event_articles(date_str: str) -> list[EnTranslatedArticle]:
|
||
"""加载指定日期的翻译+事件文章。
|
||
|
||
Args:
|
||
date_str: 日期 YYYYMMDD
|
||
|
||
Returns:
|
||
EnTranslatedArticle 列表
|
||
"""
|
||
base_dir = Path(f"data/events/{date_str}")
|
||
if not base_dir.exists():
|
||
return []
|
||
|
||
articles: list[EnTranslatedArticle] = []
|
||
for json_file in sorted(base_dir.glob("*.json")):
|
||
if json_file.name == "index.json":
|
||
continue
|
||
try:
|
||
data = json.loads(json_file.read_text(encoding="utf-8"))
|
||
article = EnTranslatedArticle(**data)
|
||
# 防御性过滤:没有英文原文的事件文件通常是 M2 no_content
|
||
# 残留,不应继续向量化。
|
||
if not (article.content_en or "").strip():
|
||
continue
|
||
articles.append(article)
|
||
except (json.JSONDecodeError, Exception) as e:
|
||
logger.warning("解析事件文章失败 %s: %s", json_file, e)
|
||
|
||
return articles
|
||
|
||
|
||
def embed_all_events(
|
||
date_str: str | None = None,
|
||
*,
|
||
model: str | None = None,
|
||
) -> dict:
|
||
"""对所有 M4 输出的文章执行向量化。
|
||
|
||
Args:
|
||
date_str: 日期 YYYYMMDD,默认当前新闻日
|
||
model: Embedding 模型名,默认从 system.yaml 读取
|
||
|
||
Returns:
|
||
统计 dict
|
||
"""
|
||
if date_str is None:
|
||
date_str = get_news_day()
|
||
|
||
logger.info("══════ 开始向量生成,日期: %s ══════", date_str)
|
||
|
||
# 加载文章
|
||
articles = _load_event_articles(date_str)
|
||
if not articles:
|
||
logger.warning("事件目录无文章: data/events/%s/", date_str)
|
||
return {"date": date_str, "total": 0, "success": 0, "failed": 0, "elapsed_sec": 0}
|
||
|
||
# 初始化 Embedding 客户端
|
||
config = load_embedding_config(model=model)
|
||
client = make_embedding_client(config)
|
||
|
||
# 输出目录
|
||
out_dir = Path(f"data/embeddings/{date_str}")
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 增量:跳过已向量化的文章
|
||
new_articles = []
|
||
skipped = 0
|
||
for a in articles:
|
||
if (out_dir / f"{a.url_hash}.json").exists():
|
||
skipped += 1
|
||
else:
|
||
new_articles.append(a)
|
||
if skipped > 0:
|
||
logger.info("增量跳过 %d 篇已向量化,剩余 %d 篇待处理", skipped, len(new_articles))
|
||
articles = new_articles
|
||
|
||
start_time = datetime.now()
|
||
success = 0
|
||
failed = 0
|
||
|
||
# 批量嵌入(按 batch_size 分块,每批输出进度)
|
||
batch_size = config.batch_size
|
||
total = len(articles)
|
||
logger.info("开始向量化 %d 篇文章(batch_size=%d, provider=%s, model=%s)",
|
||
total, batch_size, config.provider, config.model)
|
||
|
||
for batch_start in range(0, total, batch_size):
|
||
batch_end = min(batch_start + batch_size, total)
|
||
batch = articles[batch_start:batch_end]
|
||
|
||
try:
|
||
results = embed_articles(client, config, batch)
|
||
for result in results:
|
||
out_file = out_dir / f"{result.url_hash}.json"
|
||
out_file.write_text(
|
||
result.model_dump_json(indent=2, ensure_ascii=False),
|
||
encoding="utf-8",
|
||
)
|
||
success += 1
|
||
|
||
logger.info(" [%d/%d] ✅ %d 篇 → %d 维向量",
|
||
batch_end, total, len(results), config.dimension)
|
||
|
||
except EmbeddingError as e:
|
||
failed += len(batch)
|
||
logger.error("批量嵌入失败 [%d-%d]: %s", batch_start, batch_end, e.reason)
|
||
except Exception as e:
|
||
failed += len(batch)
|
||
logger.exception("批量嵌入异常 [%d-%d]: %s", batch_start, batch_end, e)
|
||
|
||
elapsed = (datetime.now() - start_time).total_seconds()
|
||
|
||
# 写入索引
|
||
_write_embedding_index(date_str, success, failed, elapsed, config)
|
||
|
||
logger.info(
|
||
"══════ 向量生成完成: 成功 %d / 失败 %d / 总计 %d,耗时 %.1f 秒 ══════",
|
||
success, failed, len(articles), elapsed,
|
||
)
|
||
|
||
return {
|
||
"date": date_str,
|
||
"total": len(articles),
|
||
"success": success,
|
||
"failed": failed,
|
||
"elapsed_sec": elapsed,
|
||
"provider": config.provider,
|
||
"model": config.model,
|
||
"dimension": config.dimension,
|
||
}
|
||
|
||
|
||
def _write_embedding_index(
|
||
date_str: str,
|
||
success: int,
|
||
failed: int,
|
||
elapsed_sec: float,
|
||
config: EmbeddingConfig,
|
||
) -> None:
|
||
"""写入向量索引文件。"""
|
||
out_dir = Path(f"data/embeddings/{date_str}")
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
index_data = {
|
||
"date": date_str,
|
||
"success": success,
|
||
"failed": failed,
|
||
"elapsed_sec": round(elapsed_sec, 1),
|
||
"provider": config.provider,
|
||
"model": config.model,
|
||
"dimension": config.dimension,
|
||
"generated_at": datetime.now().isoformat(),
|
||
}
|
||
|
||
index_path = out_dir / "index.json"
|
||
index_path.write_text(
|
||
json.dumps(index_data, indent=2, ensure_ascii=False),
|
||
encoding="utf-8",
|
||
)
|
||
logger.info("向量索引已写入: %s", index_path)
|