Excel 解析、 PDF 结构化、 PG-BM25 检索、 Redis 流式推送分属不同业务域,无法统一划入 RAG 体系。本文跳过宏观架构,针对性深挖四项关键细节的设计动因:
Excel 入 DuckDB 临时表的选型,搭配 DA Agent 异常 SQL 重试逻辑;
PDF 图文分类策略与 Chunk 驱动知识图谱实体、关系抽取方案;
Chunk 表、Embedding 表字段规划, pg_search 下 BM25 索引构建方式;
Redis 流式承载模型输出, offset 、事件字段、 SSE 回放的协议设计。
四大易被忽略的工程边界,即为本文的拆解重心。
Excel 如果只看“表格转文本”,容易只看到普通 chunk ,漏掉 DuckDB 分析链路。源码里其实有两个层次。
第一层,它会像普通文档一样被解析成 row chunk 。
第二层,它会被加载进 DuckDB ,用 SQL 做真正的数据分析。
这两个层次解决的问题不同。
普通解析:每一行变成一个文本 chunk
Excel parser 使用 pandas.ExcelFile 读取文件,遍历所有 sheet ,把每个 sheet 读成 DataFrame ,删除全空行,然后把每一行转换成:
每一行生成一个 chunk 。
这个方式适合让普通检索命中某些具体行,但它有一个限制:普通 row chunk 默认没有把 sheet 名写进去。除非原始表格里本来就有 sheet 字段,否则这些行级 chunk 天然不知道自己来自哪个 sheet 。
DataTableSummaryTask :上传 Excel 时额外生成表摘要
系统会额外入队 DataTableSummaryTask。
这条任务不是为了回答某个具体问题,而是为了提前生成可检索的表格元信息:
它的流程是:
这里的样本数据只取前 10 行,但 schema 会包含全部列名、列类型、行数和列数。
一个 Excel 不是多个表,而是一张合并表
多 sheet Excel 的处理方式也很关键。
WeKnora 当前不是:
而是:
如果只有一个 sheet ,也会加:
如果有多个 sheet ,会生成类似:
UNION ALL BY NAME 的好处是按列名对齐:
如果用户要查某个 sheet ,就用:
如果要按 sheet 聚合:
如果枚举 sheet 失败,系统 fallback 到只读第一个 sheet ,这时不会加 __sheet_name。
为什么大部分列都是 VARCHAR
DuckDB 读取 Excel/CSV 时用了:
这让 schema 更稳定,但也带来一个后果:数值计算时经常要显式 cast 。
例如:
如果模型直接生成:
就可能因为 "销售额" 是 VARCHAR 而失败。
普通问答链路里的 DataAnalysis :只生成一次 SQL
普通知识库问答 pipeline 里有一个可选的 DATA_ANALYSIS 阶段。
触发条件大致是:
它的流程是:
这条链路不走 Agent Loop 。 SQL 失败后不会自动修复,而是跳过这个阶段继续回答。
Data Analyst Agent :失败结果会进入下一轮
和普通问答链路相比, Data Analyst Agent 的关键差异是会把失败结果带回下一轮。
它核心有三个工具:
data_schema 不现场读 DuckDB ,它读取已经保存的:
所以如果表格摘要任务失败,data_schema 可能找不到 schema 信息。
data_analysis 会现场把文件加载到 DuckDB 并执行 SQL 。它有几层安全限制:
Data Analyst Agent 的系统 prompt 明确要求:
整个 loop 简化后是:
失败之所以能重试,是因为工具失败结果会被追加成 role=tool 的消息,并附带类似提示:
这比普通问答链路里的单轮 DataAnalysis 更适合复杂表格问题。尤其是 Excel 列名不规范、数值列是字符串、需要按 sheet 过滤、第一次 SQL 写错的场景。
WeKnora 处理 PDF 的第一件事,不是做一个严格的“文本型 PDF / 扫描件 PDF”分类器。
它采用的是更工程化的责任链:
也就是说,系统先让 MarkItDown 尝试解析 PDF 文本。如果解析后得到的 content 非空,就认为这份 PDF 可以走文本型链路;如果 MarkItDown 抛异常,或者解析结果为空,才 fallback 到扫描件链路。
判断逻辑可以简化成:
这个实现的价值在于:它不追求完美分类,而是关注“当前解析器有没有产出可用文本”。
这也带来几个边界情况:
所以源码里的判断更像一个启发式工程策略:
文本型 PDF :直接转 Markdown
文本型 PDF 走 MarkitdownParser。
核心动作是把 PDF 转成 Markdown/text :
Go 侧后续拿到的是:
再继续进入:
需要注意的是,文本型 PDF 里的图片不一定都能被稳定抽出。只有解析结果里真的存在图片引用,并且开启了多模态,后续才会触发 VLM OCR / Caption
扫描件 PDF :先把每页渲染成图片
扫描件走 PDFScannedParser。
它不是直接 OCR ,而是先把每一页 PDF 渲染成 JPEG :
扫描件渲染默认配置是:
DPI 可以提高,比如调到 300 , OCR 可能更稳,但图片体积、延迟和 VLM 成本也会上升。
ImageResolver :它不 OCR ,只负责统一图片引用
PDF 解析后,图片会先经过 ImageResolver 。
ImageResolver 做的不是 OCR ,而是图片引用整理:
这些图片会被统一保存到文件服务,得到类似 provider:// 的 URL ,然后把 Markdown 中原始图片地址替换掉。
只有 ImageResolver 找到的 StoredImage,后面才会进入 VLM OCR / Caption 。
扫描件 OCR 结果怎么进入索引
扫描 PDF 的有效文本,最终通常来自这条链路:
如果是扫描 PDF , OCR prompt 会切换成扫描件专用版本。它要求模型:
忽略页眉、页脚和页码 尽量保留段落和层级结构 表格用 Markdown table 表达 公式用 LaTeX 只输出抽取文本,不输出解释
OCR 结果会保存成子 chunk :
这点很关键:扫描件不是“直接 OCR 后回答”,而是 OCR 结果重新变成 chunk ,再进入统一的索引和检索流程。
图谱:node和relation是从 chunk.Content 里抽出来的
PDF 解析里还有一个值得单独讲的点:知识图谱。
WeKnora 的图谱不是向量库自动生成的,也不是 embedding 聚类出来的。
它的来源很明确:
触发条件大致是:
每个 text chunk 会单独创建一个异步抽取任务,每个任务单独调一次 LLM 。业务输入就是当前 chunk 的 Content。
抽取配置 ExtractConfig 包含:
这里的 tags 容易误解。它不是普通业务标签,而是允许的关系类型列表,会被填进 prompt 。
默认图谱抽取 prompt 的核心约束可以概括为两步:
如果换成 prompt ,大概就是:
模型实际需要返回类似这样的结构:
清洗逻辑比较轻:
但它没有做强实体归一。比如:
如果模型输出成不同名字,它们就可能成为不同节点。
写入 Neo4j 时,节点 merge 的核心近似是:
关系 merge 的核心近似是:
查询时,系统先从用户问题里抽实体,再去 Neo4j 查相邻节点和关系,拿节点上的 chunks 属性,最后反查 chunks 表拿原文。
所以图谱检索不是替代普通检索,而是和 HybridSearch 并行:
chunks 是知识库检索的基础数据表。
文档解析后会切成 chunk , chunk 再进入 embedding 和关键词索引。问答最终引用的原文通常来自这里。
核心字段包括:
这里面有几个字段特别关键。
content 是原始 chunk 正文。
pre_chunk_id 和 next_chunk_id 用来保留前后文关系。
parent_chunk_id 用来表示父子关系,比如图片 OCR / Caption 子 chunk 会挂在原图片所在 chunk 下。
chunk_type 表示 chunk 类型,常见值包括:
所以 chunks 不只是“文本切片表”,它更像知识内容的结构化证据表。
embedding 输入不等于 chunks.content
普通文本 chunk 的 embedding 输入,不是简单拿 chunks.content。
这里要先区分两个目标:检索需要更多上下文,证据展示需要保留原文。 WeKnora 的做法是给索引文本补上下文,但不改原始 chunk 正文。
源码里会构造:
例如原始 chunk 是:
文档标题是:
章节上下文是:
真正进入 embedding 的内容类似这样:
而 chunks.content 仍然只保存正文:
这个差异很重要。系统会给 embedding 喂一份“带标题和章节信息的增强文本”,让这段内容更容易在语义检索和关键词检索里被命中;但真正回显给用户、作为证据引用的,仍然是 chunks.content 里的原始正文。
可以这样理解:
写入 embedding 前还会做两类处理:
embeddings 表:不是原始 chunk 表,而是索引表
PostgreSQL 下的 embeddings 表核心字段包括:
source_type 里:
content 是 IndexInfo.Content,不是直接等于 chunks.content。
embedding 是 pgvector 的 halfvec。
chunk_id 用来命中后回查原始 chunk 。
所以普通问答链路里的几类检索实际查的东西不同:
pg_search BM25 :关键词检索不是 LIKE ,也不是 tsvector
WeKnora在 PostgreSQL里做 BM25 ,依赖的是 ParadeDB 的 pg_search 扩展。
它不是:
而是:
建索引的核心 SQL 是:
这里为什么索引里放了这么多字段?
不是为了让它们都参与分词。
各字段分工是:
如果 BM25 索引只有 content,系统可能要先命中大量内容,再回表过滤知识库和文档范围。
把 knowledge_base_id、knowledge_id、chunk_id 放进索引后,关键词检索阶段就能带上业务过滤和定位信息。
查询怎么写
关键词查询大致是:
这里:
命中以后,系统仍然会根据 chunk_id 回到 chunks 表,拿原始 chunk 、图片信息、前后文和引用展示需要的信息。
所以它不是为了“完全不回表”,而是为了让关键词召回阶段更贴近业务过滤和定位。
SQLite 的对应实现不同
如果是 SQLite ,关键词和向量的实现换成另一套:
所以同样是“关键词 + 向量”, PostgreSQL 和 SQLite 的底层实现并不一样。
第四块,是 WeKnora 的流式输出。
它不是模型直接把 token 推给前端,而是中间加了一层 Redis 事件流。
整体流程可以画成:
这套设计可以从事件类型、 Redis key 、 offset 、完成语义、断线续传和最终落库几个点来看。
事件类型在写 Redis 前就要确定
统一事件结构大致是:
事件类型包括:
关键点是: Redis 不负责判断事件类型。
类型必须在写入 Redis 前,由模型适配层或 Agent 层确定。
例如:
所以 Redis 做的是有序暂存,不做语义判断。
Redis Key :一条 assistant message 一条流
推荐 key 形式是:
为什么要带 message_id?
因为一个 session 里可能有多轮回答。每条 assistant message 都应该有自己的流。断线续传时,也要精确恢复某一条回答,而不是整个会话。
如果是多租户系统,也可以加入 tenant :
写入事件时使用:
RPUSH 保证顺序追加,EXPIRE 保证生成结束后一段时间自动清理。生成过程中每次写入都会刷新 TTL ,避免正在生成的流提前过期。
offset 不是 Stream ID ,而是 List 下标
这是 Redis 通讯里最容易讲清楚也最容易误解的点。
WeKnora 当前用的是 Redis List ,不是 Redis Stream 。
所以 offset 不是 Redis Stream ID ,也不是时间戳。
它就是 List 数组下标:
读取逻辑是:
例子:
第一次读取:
新写入两个事件:
继续读取:
SSE handler 每 100ms 做一次这个动作。
done 和 complete 不是一回事
事件里的 done 表示当前事件片段或当前事件组结束。
比如:
它只说明 thinking 阶段结束,不说明整条回答结束。
真正标记整条 assistant message 结束的是:
所以前端应该以 complete 事件作为关闭 SSE 的信号,而不是看到 done=true 就结束。
stop 也作为一种事件写入 Redis 。用户点击停止时,后端写入 stop 事件。 SSE handler 读到后通知前端停止;生成 Worker / Agent 也可以轮询同一个 StreamManager ,发现 stop 后 cancel 。
这个设计天然适合多实例:停止请求打到任意实例,生成实例都能通过 Redis 看到 stop 事件。
断线续传:简单方案从 0 replay
断线续传有两种方式。
增强方案是前端携带 lastOffset :
但当前项目采用的是简单方案:
优点是简单:
前端不用维护 offset 后端不用保存每个客户端的消费位点 重连逻辑稳定
缺点是会重复发送历史事件。
所以前端需要做幂等处理,或者 replay 前清空当前未完成消息再重放。
Redis 不是最终消息存储
还有一个重要边界:
complete 时:
停止时,也会保存已经生成的部分内容,并标记 message completed 或 stopped 。
这套设计的分工很明确:
为什么不用 Redis Stream ?
夜雨聆风