紧接系列第六、七篇(故事 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_modehybrid(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 已补 narratingawaiting_text_reviewawaiting_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

九、结语

本轮交付:

  1. 故事 RAG hybrid:keyword/jieba + 向量,generation.py 契约不变
  2. 双 RAG 架构对齐:与 image_rag/ 同 embedding 模型与缓存文件约定。
  3. 检索可观测retrieval_mode 进入 capabilities / preview / 日志。
  4. 创作可预期:imaging / narrating 第 N/M 页 进入 UI,减少长时间等待焦虑。
Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐