把可视化工作流改成纯 Python · 重点路线:数字 + 归因混合(composite)· 代码均经实跑验证
RAGSpine 不是一个你"提交给它"的框架,而是一个你用普通 Python 把零件拼起来的后端 RAG 引擎。
它有两条被代码强制(不是写在提示词里求模型听话)的铁律:
source_doc_id + 定位符,result.sources 是不变量。ragspine quickstart # 10 秒离线演示:一个命中(带来源) + 一个诚实"查不到"
ragspine ask --db data/fact_metric.db "中国内地FY2024的REVENUE是多少"mock 模式完全离线、确定性,不需要任何 API key。先跑 scripts/run_demo.py 生成示例库。
整个最小可用 API 就是这四个名字:Fact / FactStore / MockProvider / answer_question。
from ragspine.storage.fact_store import Fact, FactStore
from ragspine.agent.llm_provider import MockProvider
from ragspine.agent.agent import answer_question
# 1. 事实库(:memory: 临时;传路径就是真 sqlite)
store = FactStore(":memory:"); store.init_schema()
# 2. 一条事实 —— 最后两个字段是它的"血缘"(来自哪个文档、文档里哪个位置)
store.upsert_facts([Fact(
metric_code="REVENUE", entity="ACME_CN", geography="CN", channel="TOTAL",
period_type="FY", period="2024", value=1320.0, unit="USD_M",
source_doc_id="ACME_FY2024_Results.pptx", source_locator="slide=6,table=1,row=2,col=3",
tags={}, dimensions={},
)])
# 3. 离线确定性 provider(无 key、无网络);上线换 AnthropicProvider 即可
provider = MockProvider()
# 4. 提问。命中 → 带来源回答;缺失 → 诚实拒答,绝不编造
result = answer_question("中国内地FY2024的REVENUE是多少", store, provider)
print(result.answer) # 确定值 + 来源,或诚实的"查不到"
print(result.sources) # [{'doc':..., 'locator':...}]answer_question(question, store, provider) 就是整个编排入口——意图解析 → 安全门 → 澄清 → 三路分流 → 工具循环 → 反幻觉兜底,全部藏在这一个函数里,你不需要画任何图。
RAGSPINE_DB_PATH=data/fact_metric.db python scripts/run_server.py --port 8000 # FastAPI
python scripts/run_worker.py # RQ worker(需 Redis)端点:POST /v1/ask · POST /v1/ingest/structured/jobs · POST /v1/ingest/narrative/jobs · GET /v1/jobs/{id}
answer_question 内部对每个问题确定性地(规则,不是模型决定)选一条路:
| 路由 | 问什么 | 怎么答 |
|---|---|---|
| structured | "数字是多少"(营收/利润/ROE…) | 查 fact_metric 表 → found / not_found / unrecognized。数字只取自事实值,模型散文不被信任。 |
| narrative | "为什么 / 发生了什么" | 混合检索(BM25 + 向量 + RRF)→ 重排 → 带引用合成。 |
| composite | 既要数字又要归因 ← 你的场景 | 两路并行跑,合并:数字段确定性合成 + 归因段带引用。 |
Dify 和 RAGSpine 不是一类东西,这决定了"迁移"到底是在做什么:
import 的库:编排是普通函数,核心不 import 任何 SDK,离线就能跑。所以"把 Dify 流程改成代码" = 把可视化 DSL 翻译成 answer_question 调用 + 必要时你自己写的几行 Python 串联。
这是判断你的流程能否直接用 RAGSpine 的关键术语,拆成几个词理解:
| 词 | 含义 |
|---|---|
| agent | 一个能自己决定下一步干什么的 LLM 循环:感知 → 决策 → 调工具 → 看结果 → 再决策,走向由模型临场判断。 |
| 多 agent | 多个这样的 agent 协作:一个"规划者"把任务拆给几个"执行者",或 agent 之间互相对话、移交任务(handoff)。 |
| 有状态 | 流程在多步/多轮间要记住中间状态(对话历史、累积结果、轮到谁)。状态在节点间流转、被不断更新,还能中途暂停等人工(human-in-the-loop)、回溯、断点续跑。 |
| 图 | 这些步骤被组织成有向图(节点=步骤,边=跳转,条件边=按状态分流),由框架(如 LangGraph)驱动。 |
对照 RAGSpine:它是无状态的单次问答——进一个问题、出一个答案。路由是确定性规则(不是模型决定跳哪条边),不保留跨请求记忆,不能中途停下等人。它故意不做多 agent 图——一旦把路由交给模型决定,就破坏了"可证明的确定性拒答"这个核心卖点。
| Dify 节点 | RAGSpine 对应 | 说明 |
|---|---|---|
| 知识检索 / Knowledge Retrieval | build_narrative_retriever | 混合检索内置,你不用配。 |
| LLM 节点 + 系统提示 | provider + 内置 prompt | 系统 prompt 由公司 profile 派生,不硬编码。 |
| 工具 / 函数调用(查数字) | 内置 query_metric | 开箱即用,无需自己 bind。 |
| 问题分类器 / 条件分支 | 内置三路分流 | answer_question 自动做,无需画分支。 |
| 变量聚合 / 引用拼接 | result.sources | 引用是代码强制的不变量。 |
| "找不到就说不知道"的提示词 | 反幻觉硬约束(控制流) | Dify 里靠提示词祈求,这里靠代码兜底。 |
| 应用配置存在 Postgres | 一段 .py 文件 | 可读、可版本控制、可 diff。 |
路由判定就三行(agent/intent.py),composite 要同时满足两个条件:
中国内地FY2024的REVENUE是多少,为什么会有这样的变化 → composite ✅
中国内地FY2024的营收是多少 → structured(只有指标,没叙事词)
中国内地业绩为什么下滑 → narrative(只有叙事词,没指标)build_narrative_retriever 只读不写,写入要先走 chunking:
from ragspine.retrieval.chunking.chunking import DocumentMeta, chunk_document
from ragspine.retrieval.chunking.chunk_store import ChunkStore
meta = DocumentMeta(doc_id=..., title=..., entity=..., geography=..., period=...,
language="zh", sensitivity="INTERNAL", # 切勿用 RESTRICTED,会被检索出口剔除
source_locator_prefix=...)
chunks = chunk_document(text, meta) # 段落贪心切块,每块自带 source_locator
ChunkStore(db).replace_doc_chunks(doc_id, chunks, valid_as_of="2025-03-31") # 幂等版本化写入从仓库根目录用 .venv/bin/python 这个脚本.py 运行。完全离线、无 key。
"""RAGSpine composite 路由端到端最小示例(纯 Python,离线,无 key)。"""
import tempfile
from datetime import date
from pathlib import Path
from ragspine.agent.agent import answer_question
from ragspine.agent.llm_provider import MockProvider
from ragspine.retrieval.chunking.chunking import DocumentMeta, chunk_document
from ragspine.retrieval.chunking.chunk_store import ChunkStore
from ragspine.retrieval.link.narrative_link import build_narrative_retriever
from ragspine.storage.fact_store import Fact, FactStore
tmp = Path(tempfile.mkdtemp(prefix="ragspine_demo_"))
fact_db, chunk_db = tmp / "facts.db", tmp / "chunks.db"
# a. 数字侧:写一条事实。注意 Fact 字段全部必填(tags/dimensions 也要显式传 {})
fact_store = FactStore(fact_db); fact_store.init_schema()
fact_store.upsert_facts([Fact(
metric_code="REVENUE", entity="ACME_CN", geography="CN", channel="TOTAL",
period_type="FY", period="2024", # 注意是 "2024" 不是 "FY2024"
value=128.6, unit="亿元",
source_doc_id="FY2024_annual_report.pptx", source_locator="p.12",
tags={}, dimensions={},
)])
# b. 叙事侧:切块灌入归因文本(带来源)
doc_id = "FY2024_mdna.pptx"
meta = DocumentMeta(doc_id=doc_id, title="FY2024 中国内地业务经营回顾",
entity="ACME_CN", geography="CN", period="2024", language="zh",
sensitivity="INTERNAL", source_locator_prefix=doc_id)
text = ("FY2024 中国内地营收同比增长主要由三方面驱动:\n"
"一是代理人渠道产能提升,活动率与人均件数双升;\n"
"二是银保渠道与头部银行的独家合作贡献了新增长极;\n"
"三是续期业务因前期高质量新单而保持高留存。")
cs = ChunkStore(chunk_db); cs.init_schema()
cs.replace_doc_chunks(doc_id, chunk_document(text, meta), valid_as_of="2025-03-31")
cs.close() # 写完即关,检索时重开同一个库
# c. 构造检索器(默认纯 BM25+RRF;传 provider 才挂 LLM 二审)
retriever, ret_store = build_narrative_retriever(chunk_db, provider=None)
# d. 提一个 composite 问题(既问数字又问归因)
provider = MockProvider(reference_date=date(2026, 6, 22))
result = answer_question(
"中国内地FY2024的REVENUE是多少,为什么会有这样的变化",
fact_store, provider,
reference_date=date(2026, 6, 22),
narrative_retriever=retriever,
)
print("route :", result.route)
print("answer :\n" + result.answer)
print("sources :", result.sources)
ret_store.close(); fact_store.close()route : composite
answer :
ACME_CN FY2024 REVENUE:128.6 亿元(来源:FY2024_annual_report.pptx · p.12)
归因分析:
基于检索到的资料: …(检索到的归因片段,带来源)
sources : [{'doc':'FY2024_annual_report.pptx','locator':'p.12'},
{'doc':'FY2024_mdna.pptx','locator':'FY2024_mdna.pptx#para1-4'}]composite 把两路结果拼接:数字段是确定性合成的"实体 期间 指标:值 单位(来源…)",归因段以"归因分析:"起头;result.sources 是两路来源之和。
Fact 字段坑(实测踩过)Fact 字段全部必填、无默认值:tags={} 和 dimensions={} 也要显式传,否则 TypeError。period_type="FY" + period="2024",不是 "FY2024"。sensitivity 不能用 RESTRICTED——叙事检索出口会把它剔除(隔离不出域硬约束),归因段会变成"未检索到相关资料"。ChunkStore.replace_doc_chunks 灌完、close,再 build_narrative_retriever 重开同一个 sqlite 文件。ingest_narrative 喂纯字符串——那个高层函数要真实 pptx/pdf 文件 + 文件 hash 登记;纯文本演示走 chunk_document + replace_doc_chunks 这条短路。| 侧 | MockProvider(离线) | AnthropicProvider(真实 Claude) |
|---|---|---|
| 数字段 | 两者完全一致——数字永远取自 fact 值,模型散文不参与(反幻觉硬约束)。 | |
| 归因段 | 离线占位:把检索到的片段原样回显(证明检索链通了、来源带上了),不会真总结。 | 基于检索片段输出真正的归因散文,并强制带来源引用。 |
MockProvider 跑通管线、写测试(离线无 key、确定性),确认行为对了;上线再换 AnthropicProvider(装 [llm] extra + API key)出真归因。叙事路径反幻觉策略与结构化不同:信任模型散文但强制来源引用(来源没出现在回答里就自动补"(资料来源:…)")。
from ragspine.agent.llm_provider import AnthropicProvider
provider = AnthropicProvider(model="claude-...", base_url="https://你的企业网关") # 需装 [llm] extra
# 其余代码不变:answer_question(question, fact_store, provider, narrative_retriever=retriever)FactStore(带血缘)。Dify 里"数据库/知识库查询"节点的结构化数据搬这里;可手写 upsert_facts,也可用 RAGSpine 的 extraction 层从 xlsx/pptx/pdf 自动抽。ChunkStore(chunk_document + replace_doc_chunks)。Dify 里"知识检索"节点的文档进这里。answer_question(...)。Dify 里"分类器 → 分支 → 检索 → LLM → 变量聚合"全部由这一个函数内部完成。route == "composite"(打印 result.route 验证)。result.sources 同时含数字侧 + 叙事侧来源。RESTRICTED 还指望它被检索到。base_url / API key / 模型名按企业网关配好。