基于 AI agent 的童话编剧与绘本生成器(八)故事 RAG 向量混合检索与创作页 imaging 页级进度
紧接系列第六、七篇(故事 RAG 关键词/jieba 注入、中文
corpus_zh.jsonl与顶栏统一),本文记录两项 P2 工程补齐:故事侧 RAG 从纯关键词升级为关键词 + DashScope 向量混合检索(与出图 RAG 架构对齐);以及 创作页将后端已有的「第 N/M 页」stage_message可视化为双轨进度条。对外reference_material字符串契约 与 Job 轮询 URL 不变,仅扩展可选响应字段与.env开关。
一、动机:第六篇的「可解释检索」与第七篇的「中文语料」之后,还缺什么?
到第七篇为止,故事 RAG 链路是:
- 离线语料
corpus.jsonl(en) 或corpus_zh.jsonl(zh); - 运行时
retrieve_for_story:英文 token / jieba 短语打分 + SHA256 确定性 fallback; - 注入点
generation.py的{reference_material},输出语言仍由language约束。
出图侧 meanwhile 已在第六篇团队迭代中落地 image_rag/:keyword + embedding hybrid、本地 *.embeddings.json 缓存、auto_harvest 语料自增长。
本轮暴露三类问题:
| 问题 | 表现 | 本轮目标 |
|---|---|---|
| 语义命中弱 | 「小狐狸找朋友」与语料标题「狐狸与森林」关键词不一定重合,Top-K 仍靠 fallback | 故事 RAG 可开关 hybrid |
| 双 RAG 叙事分裂 | 答辩需解释「出图有向量、故事没有」 | 与 image_rag/embeddings.py 同模型、同缓存模式 |
| 等待黑盒 | 后端已写 正在绘制第 3/8 页插图,前端只显示「正在绘制绘本插图(72%)」 |
页级子进度条 + Job API 结构化字段 |
刻意不动的边界:
generation.py仍只消费字符串reference_material;- SQLite
generation_tasks不新增列,继续以stage_message为页级进度单一真相来源。
二、双 RAG 对称:出图与故事共用一套 embedding 基建
2.1 对照表(答辩可口述)
| 维度 | 出图 RAG image_rag/ |
故事 RAG rag/(本篇) |
|---|---|---|
| 语料 | image_corpus.jsonl |
corpus.jsonl / corpus_zh.jsonl |
| 向量缓存 | {stem}.embeddings.json |
同左 |
| 模型 | text-embedding-v3 |
同左 |
| 默认权重 | 0.45 kw + 0.55 vec | 同左(独立 .env 可调) |
| Key 来源 | ImageProvider().api_key() |
同左 |
| 注入形态 | 出图 LLM JSON 正负例块 | reference_material 字符串 |
| 关闭 embedding | IMAGE_RAG_EMBEDDING_ENABLED=false |
RAG_EMBEDDING_ENABLED=false |
2.2 为什么不在第一版就上向量库?
与第六篇立场一致:演示路径不绑 Milvus/PGVector。本地 JSON 缓存 + DashScope TextEmbedding 已满足实训「可部署、可 diff、可关开关对比」;向量库可作为后续 rerank 层 的替换,而不动 generation.py。
三、故事 RAG:embeddings.py 与向量缓存
3.1 缓存路径与 embedding 文本
语料文件名 corpus_zh.jsonl → 缓存 corpus_zh.embeddings.json,与 jsonl 同目录:
def _cache_path(corpus_filename: str) -> Path:
stem = Path(corpus_filename).stem
return data_root() / "knowledge" / f"{stem}.embeddings.json"
def _record_embed_text(rec: KnowledgeRecord) -> str:
tags = " ".join(rec.theme_tags)
body = (rec.body or "").replace("\n", " ")[:800]
return f"{rec.title} {tags} {body}"
思考要点:
- 按 stem 隔离:切换
RAG_CORPUS_FILENAME不会误用另一套向量。 - body 截断 800 字:控制 embedding 费用;标题与 tags 仍占主要权重。
- 进程内 mtime 缓存:与
corpus_loader的 jsonl mtime 策略一致。
3.2 批量补向量与查询向量
def ensure_record_vectors(
records: list[KnowledgeRecord],
*,
corpus_filename: str,
) -> dict[str, list[float]]:
vectors = _load_cache(corpus_filename)
missing: list[KnowledgeRecord] = [r for r in records if r.id not in vectors]
...
batch_size = 10
for i in range(0, len(missing), batch_size):
batch = missing[i : i + batch_size]
texts = [_record_embed_text(r) for r in batch]
embs = embed_texts(texts)
...
if added:
_save_cache(corpus_filename, vectors)
return vectors
def query_embedding(
*,
scene: str,
protagonist: str,
extra_prompt: str,
interest_tags: list[str],
) -> list[float] | None:
tags = " ".join(interest_tags or [])
text = f"{scene} {protagonist} {extra_prompt or ''} {tags}".strip()
...
embs = embed_texts([text[:500]])
return embs[0] if embs else None
降级路径: RAG_EMBEDDING_ENABLED=false、无 Key、dashscope 导入失败或 API 报错 → embed_texts 返回 None → retriever 整路 keyword。
四、混合检索:_hybrid_pick_records
4.1 打分公式
在保留第七篇 jieba / 英文 token 打分函数 _score_record_zh / _score_record_en 的前提下,新增 hybrid 层:
def _hybrid_pick_records(
records: list[KnowledgeRecord],
terms: QueryTerms,
...
) -> tuple[list[KnowledgeRecord], list[float], str]:
query_vec: list[float] | None = None
retrieval_mode = "keyword"
if settings.rag_embedding_enabled:
query_vec = query_embedding(
scene=payload.scene,
protagonist=payload.protagonist,
extra_prompt=payload.extra_prompt or "",
interest_tags=list(payload.interest_tags or []),
)
if query_vec:
retrieval_mode = "hybrid"
vectors = ensure_record_vectors(records, corpus_filename=corpus_filename) if query_vec else {}
...
if query_vec:
hybrid = kw_weight * (kw / max_kw if max_kw else 0.0) + vec_weight * vec_sim
else:
hybrid = kw
...
return picked[:top_k], final_scores[:top_k], retrieval_mode
与出图 RAG 同构: 关键词分归一化后乘 RAG_KEYWORD_WEIGHT,余弦相似度乘 RAG_VECTOR_WEIGHT;不足 Top-K 仍走第六篇 SHA256 fallback。
4.2 日志与返回值
retrieve_for_story 返回 RagRetrieveResult(..., retrieval_mode);日志示例:
rag_retrieval mode=hybrid lang=zh top_k=3 ids=['ft_zh_0042', ...] scores=[0.712, ...] ...
generation.py 无改动——仍调用 _reference_material_for_generation,只消费 result.reference_block。
五、运维开关与验收 API
5.1 settings / .env
rag_enabled: bool = False
rag_corpus_filename: str = "corpus.jsonl"
rag_corpus_language: str = "auto"
rag_top_k: int = 3
...
rag_embedding_enabled: bool = True
rag_embedding_model: str = "text-embedding-v3"
rag_keyword_weight: float = 0.45
rag_vector_weight: float = 0.55
推荐答辩 .env 片段(中文 demo):
5.2 零 LLM 成本验收
能力自检:
curl http://127.0.0.1:8000/health/capabilities
# 关注 rag_embedding_enabled、rag_retrieval_mode
预览检索(force_retrieve: true 可在 RAG_ENABLED=false 时仍检索):
curl -X POST http://127.0.0.1:8000/api/v1/rag/preview `
-H "Content-Type: application/json" `
-d "{\"scene\":\"神秘森林\",\"protagonist\":\"勇敢小狮子\",\"force_retrieve\":true}"
响应 retrieval_mode 为 hybrid(Key 可用)或 keyword.
keyword vs hybrid 对比: 同一 payload,仅切换 RAG_EMBEDDING_ENABLED,各 preview 一次,记录 Top-3 id 差异——无需调用 Chat 模型。
六、imaging 页级进度:后端解析 + Job 响应扩展
6.1 既有写入:stage_message 已是单一真相
出图阶段 job_sqlite 早已更新页级文案:
def update_pipeline_media_progress(
...
) -> None:
...
message = f"正在绘制第 {page}/{total} 页插图"
if tts_enabled and tts_total > 0:
message += f" · 朗读 {min(tts_done, tts_total)}/{tts_total} 页"
self.update_stage(
job_id=job_id,
status="imaging",
progress=progress,
stage_message=message,
)
独立 narrating 阶段:
message = f"正在合成第 {page}/{total} 页朗读"
6.2 解析层:不加 DB 列
新增 job_media_progress.py,正则提取 N/M:
_IMAGING_RE = re.compile(r"正在绘制第\s*(\d+)\s*/\s*(\d+)\s*页")
_NARRATING_RE = re.compile(r"正在合成第\s*(\d+)\s*/\s*(\d+)\s*页")
_PIPELINE_TTS_RE = re.compile(r"朗读\s*(\d+)\s*/\s*(\d+)\s*页")
...
def parse_job_media_progress(stage_message: str | None, *, status: str | None = None) -> JobMediaProgress:
...
_row_to_status 写入 JobStatusResponse:
def _row_to_status(row: GenerationTaskRow) -> JobStatusResponse:
status = row.status
media = parse_job_media_progress(row.stage_message, status=status)
return JobStatusResponse(
...
stage_message=(row.stage_message or None),
imaging_page=media.imaging_page,
imaging_total=media.imaging_total,
narrating_page=media.narrating_page,
narrating_total=media.narrating_total,
...
)
向后兼容: 旧 Job 或无匹配文案时四字段为 null,前端隐藏页级子条。
七、前端:创作页双轨进度条
7.1 UI 结构
create.html / create-step.html 增加 #jobProgressPanel:
| 元素 | 绑定 |
|---|---|
| 总进度条 | job.progress(0–100) |
| 页级子条 | imaging_page / imaging_total 或 narrating 字段 |
| 阶段文案 | 优先 stage_message;兜底 map 已补 narrating、awaiting_text_review、awaiting_storyboard_review |
轮询逻辑不变:GET /api/v1/jobs/{id} 每 1.5s;在 pollJobUntilComplete 内调用 updateJobProgressUI(job)。
7.2 样式
styles.css 新增 .job-progress-panel:暖色总条(--terracotta → --amber)+ 绿色页级条(--sage),与第七篇全站绘本风一致。
八、问题与取舍
| 问题 | 处理 |
|---|---|
| 大语料(1651 条 en)首次 embedding 慢 | 本地 JSON 缓存;答辩前 POST /rag/preview 预热 |
| 无 DashScope Key | 自动 retrieval_mode=keyword,与第六篇行为一致 |
stage_message 文案变更 |
解析集中在 job_media_progress.py 一处 |
| create / create-step JS 重复 | 本轮内联;后续可抽 job-progress.js |
| scripting 无页级进度 | 本轮聚焦 imaging / narrating;文本阶段仍用阶段级 progress |
九、结语
本轮交付:
- 故事 RAG hybrid:keyword/jieba + 向量,
generation.py契约不变。 - 双 RAG 架构对齐:与
image_rag/同 embedding 模型与缓存文件约定。 - 检索可观测:
retrieval_mode进入 capabilities / preview / 日志。 - 创作可预期:imaging / narrating 第 N/M 页 进入 UI,减少长时间等待焦虑。
更多推荐

所有评论(0)