乐于分享
好东西不私藏

WeKnora源码四项关键细节

WeKnora源码四项关键细节
梳理 WeKnora 源码,项目亮眼之处不在于堆砌成熟大框架,而是多处落地导向的工程权衡设计。

Excel 解析、 PDF 结构化、 PG-BM25 检索、 Redis 流式推送分属不同业务域,无法统一划入 RAG 体系。本文跳过宏观架构,针对性深挖四项关键细节的设计动因:

  • Excel 入 DuckDB 临时表的选型,搭配 DA Agent 异常 SQL 重试逻辑;

  • PDF 图文分类策略与 Chunk 驱动知识图谱实体、关系抽取方案;

  • Chunk 表、Embedding 表字段规划, pg_search 下 BM25 索引构建方式;

  • Redis 流式承载模型输出, offset 、事件字段、 SSE 回放的协议设计。

四大易被忽略的工程边界,即为本文的拆解重心。

Excel:DuckDB 临时表“把表格转文本”更进一步

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。

这条任务不是为了回答某个具体问题,而是为了提前生成可检索的表格元信息:

table_summary
table_column

它的流程是:

LoadFromKnowledge
  -> materializeKnowledgeFile
  -> LoadFromExcel / LoadFromCSV
  -> DuckDB CREATE TABLE
  -> DESCRIBE table
  -> COUNT(*)
  -> SELECT * LIMIT 10
  -> LLM 生成 table summary
  -> LLM 生成 column summary
  -> 保存 table_summary / table_column chunks
  -> embedding / keyword index

这里的样本数据只取前 10 行,但 schema 会包含全部列名、列类型、行数和列数。

一个 Excel 不是多个表,而是一张合并表

多 sheet Excel 的处理方式也很关键。

WeKnora 当前不是:

一个 sheet -> 一张 DuckDB 表

而是:

一个 Excel 文件 -> 一张 DuckDB 表
所有 sheet -> UNION ALL BY NAME 合并
额外加 __sheet_name 标记来源

如果只有一个 sheet ,也会加:

__sheet_name

如果有多个 sheet ,会生成类似:

CREATE TABLE "k_xxx" AS
SELECT *, 'Sheet1' AS __sheet_name
FROM read_xlsx('file.xlsx', sheet = 'Sheet1', header=true, all_varchar=true)
UNION ALL BY NAME
SELECT *, 'Sheet2' AS __sheet_name
FROM read_xlsx('file.xlsx', sheet = 'Sheet2', header=true, all_varchar=true)

UNION ALL BY NAME 的好处是按列名对齐:

•某个 sheet 缺少某列,该列补 NULL
•某个 sheet 多出某列,最终表包含这列
•同名列合并成同一列

如果用户要查某个 sheet ,就用:

SELECT *
FROM "k_xxx"
WHERE "__sheet_name" = '华东'
LIMIT 100

如果要按 sheet 聚合:

SELECT "__sheet_name", COUNT(*) AS row_count
FROM "k_xxx"
GROUP BY "__sheet_name"
ORDER BY row_count DESC

如果枚举 sheet 失败,系统 fallback 到只读第一个 sheet ,这时不会加 __sheet_name。

为什么大部分列都是 VARCHAR

DuckDB 读取 Excel/CSV 时用了:

all_varchar = true

这让 schema 更稳定,但也带来一个后果:数值计算时经常要显式 cast 。

例如:

SELECT SUM(CAST("销售额" AS DOUBLE)) AS "销售额总和"
FROM "k_abc"
WHERE "__sheet_name" = '华东'

如果模型直接生成:

SELECT SUM("销售额") FROM "k_abc"

就可能因为 "销售额" 是 VARCHAR 而失败。

普通问答链路里的 DataAnalysis :只生成一次 SQL

普通知识库问答 pipeline 里有一个可选的 DATA_ANALYSIS 阶段。

触发条件大致是:

当前问答需要 retrieval
检索结果中包含 CSV / Excel 文件
DataAnalysisEnabled 开启

它的流程是:

检索命中 Excel / CSV
  -> 加载第一个 data file 到 DuckDB
  -> 获取 schema.Description()
  -> LLM 一次性生成 DataAnalysisInput JSON
  -> DataAnalysisTool.Execute()
  -> 查询结果作为 SearchResult 加入回答上下文

这条链路不走 Agent Loop 。 SQL 失败后不会自动修复,而是跳过这个阶段继续回答。

Data Analyst Agent :失败结果会进入下一轮

和普通问答链路相比, Data Analyst Agent 的关键差异是会把失败结果带回下一轮。

它核心有三个工具:

data_schema
data_analysis
final_answer

data_schema 不现场读 DuckDB ,它读取已经保存的:

table_summary
table_column

所以如果表格摘要任务失败,data_schema 可能找不到 schema 信息。

data_analysis 会现场把文件加载到 DuckDB 并执行 SQL 。它有几层安全限制:

只允许 SELECT / SHOW / DESCRIBE / EXPLAIN / PRAGMA
只允许访问当前知识对应的表
只允许单条 SQL
禁止危险函数
尝试修正列名大小写、空格差异

Data Analyst Agent 的系统 prompt 明确要求:

Schema First:
  写 SQL 前必须先调用 data_schema
Read-Only:
  只能 SELECT,不能 INSERT / UPDATE / DELETE / CREATE / DROP
Iterative Refinement:
  如果查询失败,分析错误并改写 SQL

整个 loop 简化后是:

Round 1:
  LLM -> data_schema
Round 2:
  LLM -> data_analysis({knowledge_id, sql})
Round 3:
  如果成功:LLM -> final_answer
  如果失败:错误结果作为 tool message 进入上下文
             LLM 根据错误重写 SQL
Round N:
  final_answer

失败之所以能重试,是因为工具失败结果会被追加成 role=tool 的消息,并附带类似提示:

[Analyze the error above and try a different approach.]

这比普通问答链路里的单轮 DataAnalysis 更适合复杂表格问题。尤其是 Excel 列名不规范、数值列是字符串、需要按 sheet 过滤、第一次 SQL 写错的场景。

PDF :不是先分类,而是用解析结果决定走哪条链路

WeKnora 处理 PDF 的第一件事,不是做一个严格的“文本型 PDF / 扫描件 PDF”分类器。

它采用的是更工程化的责任链:

MarkitdownParser -> PDFScannedParser

也就是说,系统先让 MarkItDown 尝试解析 PDF 文本。如果解析后得到的 content 非空,就认为这份 PDF 可以走文本型链路;如果 MarkItDown 抛异常,或者解析结果为空,才 fallback 到扫描件链路。

判断逻辑可以简化成:

MarkItDown 解析后 content 非空
  -> 文本型 PDF 链路
MarkItDown 失败或 content 为空
  -> 扫描件 PDF 链路

这个实现的价值在于:它不追求完美分类,而是关注“当前解析器有没有产出可用文本”。

这也带来几个边界情况:

•扫描件 PDF 如果带隐藏文字层, MarkItDown 可能输出非空文本,于是不会进入扫描件 OCR 。
•文本型 PDF 如果 MarkItDown 解析失败,也可能被当成扫描件渲染。
•混合型 PDF 不是逐页判断,而是整个 PDF 按解析器链路走。

所以源码里的判断更像一个启发式工程策略:

不是判断 PDF 本质类型
而是判断当前解析结果是否有效

文本型 PDF :直接转 Markdown

文本型 PDF 走 MarkitdownParser。

核心动作是把 PDF 转成 Markdown/text :

result = self.markitdown.convert(
    io.BytesIO(content),
    file_extension=ext,
    keep_data_uris=True
)
return Document(content=result.text_content)

Go 侧后续拿到的是:

ReadResult.MarkdownContent
ReadResult.ImageRefs
ReadResult.Metadata

再继续进入:

ImageResolver
  -> Go chunker
  -> processChunks
  -> chunks 表
  -> embedding / keyword index

需要注意的是,文本型 PDF 里的图片不一定都能被稳定抽出。只有解析结果里真的存在图片引用,并且开启了多模态,后续才会触发 VLM OCR / Caption 

扫描件 PDF :先把每页渲染成图片

扫描件走 PDFScannedParser。

它不是直接 OCR ,而是先把每一页 PDF 渲染成 JPEG :

每页 PDF
  -> render 成 JPEG
  -> Markdown 里插入图片引用
  -> 图片 bytes 放进 Document.images
  -> metadata 标记 image_source_type = scanned_pdf

扫描件渲染默认配置是:

DOCREADER_PDF_RENDER_DPI = 200
DOCREADER_PDF_JPEG_QUALITY = 90

DPI 可以提高,比如调到 300 , OCR 可能更稳,但图片体积、延迟和 VLM 成本也会上升。

ImageResolver :它不 OCR ,只负责统一图片引用

PDF 解析后,图片会先经过 ImageResolver 。

ImageResolver 做的不是 OCR ,而是图片引用整理:

Markdown 图片
Markdown data URI
HTML img data URI
裸 base64 图片
远程 http/https 图片

这些图片会被统一保存到文件服务,得到类似 provider:// 的 URL ,然后把 Markdown 中原始图片地址替换掉。

只有 ImageResolver 找到的 StoredImage,后面才会进入 VLM OCR / Caption 。

扫描件 OCR 结果怎么进入索引

扫描 PDF 的有效文本,最终通常来自这条链路:

PDFScannedParser 渲染页面图片
  -> ImageResolver 保存图片
  -> enqueue image:multimodal task
  -> VLM OCR
  -> 创建 image_ocr chunk
  -> image_ocr chunk 进入 embedding / keyword index

如果是扫描 PDF , OCR prompt 会切换成扫描件专用版本。它要求模型:

  • 忽略页眉、页脚和页码
  • 尽量保留段落和层级结构
  • 表格用 Markdown table 表达
  • 公式用 LaTeX
  • 只输出抽取文本,不输出解释

OCR 结果会保存成子 chunk :

ChunkTypeImageOCR     = image_ocr
ChunkTypeImageCaption = image_caption
ParentChunkID         = 原图片所在 chunk

这点很关键:扫描件不是“直接 OCR 后回答”,而是 OCR 结果重新变成 chunk ,再进入统一的索引和检索流程。

图谱:node和relation是从 chunk.Content 里抽出来的

PDF 解析里还有一个值得单独讲的点:知识图谱。

WeKnora 的图谱不是向量库自动生成的,也不是 embedding 聚类出来的。

它的来源很明确:

text chunk
  -> LLM 抽取 entity / relation JSON
  -> 清洗
  -> 写入 Neo4j

触发条件大致是:

kb.IsGraphEnabled()
NEO4J_ENABLE = true
ExtractConfig.Enabled = true

每个 text chunk 会单独创建一个异步抽取任务,每个任务单独调一次 LLM 。业务输入就是当前 chunk 的 Content。

抽取配置 ExtractConfig 包含:

enabled   是否启用
tags      允许的关系类型列表
text      few-shot 示例文本
nodes     示例中期望抽出的节点
relations 示例中期望抽出的关系

这里的 tags 容易误解。它不是普通业务标签,而是允许的关系类型列表,会被填进 prompt 。

默认图谱抽取 prompt 的核心约束可以概括为两步:

Step 1: Entity Extraction and Attribute Enrichment
  抽取核心实体,并补充文本中明确提到的属性
Step 2: Relationship Extraction and Verification
  关系类型只能从指定列表中选择
  只抽取文本中真实存在的关系
  明确关系两端实体
  如果关系有补充属性,也一起补充

如果换成 prompt ,大概就是:

请基于给定文本完成知识图谱抽取任务。
第一步:实体抽取与属性补充
1. 抽取文本中的核心实体,实体可以是人物、组织、产品、合同、地点、金额、条款等。
2. 为每个实体补充文本中明确提到的属性,不要编造。
第二步:关系抽取与校验
1. 关系类型只能从以下列表中选择:%s。
2. 只抽取文本中明确存在的关系,不要臆造关系。
3. 每条关系必须明确给出两个实体端点。
4. 输出纯 JSON,不要输出解释。

模型实际需要返回类似这样的结构:

[
  {
    "entity": "腾讯云计算有限公司",
    "entity_attributes": ["甲方"]
  },
  {
    "entity": "深圳某科技有限公司",
    "entity_attributes": ["乙方"]
  },
  {
    "entity": "软件采购合同",
    "entity_attributes": ["合同金额100万元"]
  },
  {
    "entity1": "腾讯云计算有限公司",
    "entity2": "深圳某科技有限公司",
    "relation": "签署合同"
  }
]

清洗逻辑比较轻:

提取 JSON
json.Unmarshal
entity 字段识别为节点
entity1/entity2 识别为关系
同名节点精确去重
关系端点不存在时自动补节点
Node1 == Node2 的关系丢掉

但它没有做强实体归一。比如:

腾讯
腾讯公司
Tencent
腾讯控股有限公司

如果模型输出成不同名字,它们就可能成为不同节点。

写入 Neo4j 时,节点 merge 的核心近似是:

labels + name + knowledge_id

关系 merge 的核心近似是:

source node + relation type + target node

查询时,系统先从用户问题里抽实体,再去 Neo4j 查相邻节点和关系,拿节点上的 chunks 属性,最后反查 chunks 表拿原文。

所以图谱检索不是替代普通检索,而是和 HybridSearch 并行:

HybridSearch:
  vector + keyword
EntitySearch:
  Neo4j node / relation
  -> node.chunks
  -> chunks table
Chunk 与 BM25

chunks 是知识库检索的基础数据表。

文档解析后会切成 chunk , chunk 再进入 embedding 和关键词索引。问答最终引用的原文通常来自这里。

核心字段包括:

id
tenant_id
knowledge_base_id
knowledge_id
tag_id
content
chunk_index
is_enabled
status
start_at
end_at
pre_chunk_id
next_chunk_id
chunk_type
parent_chunk_id
image_info
relation_chunks
indirect_relation_chunks
metadata
content_hash

这里面有几个字段特别关键。

content 是原始 chunk 正文。

pre_chunk_id 和 next_chunk_id 用来保留前后文关系。

parent_chunk_id 用来表示父子关系,比如图片 OCR / Caption 子 chunk 会挂在原图片所在 chunk 下。

chunk_type 表示 chunk 类型,常见值包括:

text
parent_text
image_ocr
image_caption
summary
entity
relationship
faq
web_search
table_summary
table_column
wiki_page

所以 chunks 不只是“文本切片表”,它更像知识内容的结构化证据表。

embedding 输入不等于 chunks.content

普通文本 chunk 的 embedding 输入,不是简单拿 chunks.content。

这里要先区分两个目标:检索需要更多上下文,证据展示需要保留原文。 WeKnora 的做法是给索引文本补上下文,但不改原始 chunk 正文。

源码里会构造:

文档标题
章节上下文 ContextHeader
chunk 正文 Content

例如原始 chunk 是:

甲方腾讯云计算有限公司与乙方深圳某科技有限公司签署软件采购合同,合同金额为100万元。

文档标题是:

2025 软件采购合同

章节上下文是:

合同主体与金额

真正进入 embedding 的内容类似这样:

2025 软件采购合同
合同主体与金额
甲方腾讯云计算有限公司与乙方深圳某科技有限公司签署软件采购合同,合同金额为100万元。

而 chunks.content 仍然只保存正文:

甲方腾讯云计算有限公司与乙方深圳某科技有限公司签署软件采购合同,合同金额为100万

这个差异很重要。系统会给 embedding 喂一份“带标题和章节信息的增强文本”,让这段内容更容易在语义检索和关键词检索里被命中;但真正回显给用户、作为证据引用的,仍然是 chunks.content 里的原始正文。

可以这样理解:

chunks.content:
  原始证据文本
  用于展示、引用、grep_chunks、拼上下文
IndexInfo.Content / embeddings.content:
  检索索引文本
  可能包含标题、章节上下文和正文
  用于 embedding 和普通关键词检索

写入 embedding 前还会做两类处理:

base64 图片 payload 替换成 [image]
单条 embedding 输入超过 20000 字符会截断

embeddings 表:不是原始 chunk 表,而是索引表

PostgreSQL 下的 embeddings 表核心字段包括:

id
source_id
source_type
chunk_id
knowledge_id
knowledge_base_id
tag_id
content
dimension
embedding
is_enabled

source_type 里:

0 = chunk
1 = passage
2 = summary

content 是 IndexInfo.Content,不是直接等于 chunks.content。

embedding 是 pgvector 的 halfvec。

chunk_id 用来命中后回查原始 chunk 。

所以普通问答链路里的几类检索实际查的东西不同:

向量检索:
  查 embeddings.embedding
普通关键词检索:
  查 embeddings.content
Agent grep_chunks:
  查 chunks.content

pg_search BM25 :关键词检索不是 LIKE ,也不是 tsvector

WeKnora在 PostgreSQL里做 BM25 ,依赖的是 ParadeDB 的 pg_search 扩展。

它不是:

LIKE
PostgreSQL 原生 tsvector
虚拟表

而是:

普通表 embeddings
  + pg_search 扩展
  + USING bm25 建索引
  + content ||| query 做匹配
  + paradedb.score(id) 算分

建索引的核心 SQL 是:

CREATE EXTENSION IF NOT EXISTS pg_search;
CREATE INDEX embeddings_search_idx ON embeddings
USING bm25 (id, knowledge_base_id, content, knowledge_id, chunk_id)
WITH (
  key_field = 'id',
  text_fields = '{
    "content": {
      "tokenizer": {"type": "chinese_lindera"}
    }
  }'
);

这里为什么索引里放了这么多字段?

不是为了让它们都参与分词。

各字段分工是:

content:
  真正被分词、匹配、BM25 打分的文本
id:
  paradedb.score(id) 需要用它计算分数
knowledge_base_id:
  检索阶段限制知识库范围
knowledge_id:
  检索阶段限制文档范围
chunk_id:
  命中后回查 chunks 表,拿原始 chunk、图片信息、上下文等

如果 BM25 索引只有 content,系统可能要先命中大量内容,再回表过滤知识库和文档范围。

把 knowledge_base_id、knowledge_id、chunk_id 放进索引后,关键词检索阶段就能带上业务过滤和定位信息。

查询怎么写

关键词查询大致是:

SELECT
  paradedb.score(id) AS score,
  id,
  content,
  source_id,
  source_type,
  chunk_id,
  knowledge_id,
  knowledge_base_id,
  tag_id
FROM embeddings
WHERE content ||| '合同编号'
  AND knowledge_base_id IN (...)
  AND (is_enabled IS NULL OR is_enabled = true)
ORDER BY score DESC
LIMIT 10;

这里:

content ||| '合同编号'
  用 pg_search 做关键词匹配
paradedb.score(id)
  返回 BM25 相关性分数
ORDER BY score DESC
  按关键词相关性排序

命中以后,系统仍然会根据 chunk_id 回到 chunks 表,拿原始 chunk 、图片信息、前后文和引用展示需要的信息。

所以它不是为了“完全不回表”,而是为了让关键词召回阶段更贴近业务过滤和定位。

SQLite 的对应实现不同

如果是 SQLite ,关键词和向量的实现换成另一套:

lite_embeddings:
  保存元数据
lite_embeddings_fts:
  FTS5 虚拟表,做关键词检索
vec_embeddings_<dimension>:
  sqlite-vec 向量虚拟表

所以同样是“关键词 + 向量”, PostgreSQL 和 SQLite 的底层实现并不一样。

Redis 临时事件流,offset 驱动 SSE replay

第四块,是 WeKnora 的流式输出。

它不是模型直接把 token 推给前端,而是中间加了一层 Redis 事件流。

整体流程可以画成:

┌──────────────┐
│ 用户发起请求 │
└──────┬───────┘
       │
       v
┌──────────────────────┐
│ 后端创建 assistant msg │
└──────┬───────────────┘
       │
       v
┌──────────────────────┐
│ LLM / Agent Producer  │
│ 生成 answer/thinking  │
│ tool_call/references  │
└──────┬───────────────┘
       │ StreamEvent
       v
┌──────────────────────┐
│ Redis List            │
│ RPUSH eventJSON       │
│ EXPIRE key ttl        │
└──────┬───────────────┘
       │ LRANGE offset -1
       v
┌──────────────────────┐
│ SSE Handler           │
│ 每 100ms 读取新增事件 │
│ Flush 到前端          │
└──────┬───────────────┘
       │ SSE message
       v
┌──────────────────────┐
│ 前端按 response_type  │
│ 渲染 answer/thinking  │
│ tool/references       │
└──────────────────────┘

这套设计可以从事件类型、 Redis key 、 offset 、完成语义、断线续传和最终落库几个点来看。

事件类型在写 Redis 前就要确定

统一事件结构大致是:

type StreamEvent struct {
    ID        string
    Type      string
    Content   string
    Done      bool
    Timestamp time.Time
    Data      map[string]interface{}
}

事件类型包括:

agent_query
thinking
answer
tool_call
tool_result
references
reflection
session_title
error
stop
complete

关键点是: Redis 不负责判断事件类型。

类型必须在写入 Redis 前,由模型适配层或 Agent 层确定。

例如:

reasoning_content / reasoning
  -> thinking
delta.content
  -> answer
AgentToolCall
  -> tool_call
AgentToolResult
  -> tool_result
AgentFinalAnswer
  -> answer
AgentComplete
  -> complete

所以 Redis 做的是有序暂存,不做语义判断。

Redis Key :一条 assistant message 一条流

推荐 key 形式是:

stream:events:<session_id>:<message_id>

为什么要带 message_id?

因为一个 session 里可能有多轮回答。每条 assistant message 都应该有自己的流。断线续传时,也要精确恢复某一条回答,而不是整个会话。

如果是多租户系统,也可以加入 tenant :

stream:events:<tenant_id>:<session_id>:<message_id>

写入事件时使用:

RPUSH key eventJSON
EXPIRE key ttl

RPUSH 保证顺序追加,EXPIRE 保证生成结束后一段时间自动清理。生成过程中每次写入都会刷新 TTL ,避免正在生成的流提前过期。

offset 不是 Stream ID ,而是 List 下标

这是 Redis 通讯里最容易讲清楚也最容易误解的点。

WeKnora 当前用的是 Redis List ,不是 Redis Stream 。

所以 offset 不是 Redis Stream ID ,也不是时间戳。

它就是 List 数组下标:

offset = 已经读取过的事件数量

读取逻辑是:

LRANGE key offset -1
nextOffset = offset + len(events)

例子:

Redis List:
index:  0   1   2   3
event:  A   B   C   D

第一次读取:

fromOffset = 0
LRANGE 0 -1 -> A B C D
nextOffset = 4

新写入两个事件:

index:  0   1   2   3   4   5
event:  A   B   C   D   E   F

继续读取:

fromOffset = 4
LRANGE 4 -1 -> E F
nextOffset = 6

SSE handler 每 100ms 做一次这个动作。

done 和 complete 不是一回事

事件里的 done 表示当前事件片段或当前事件组结束。

比如:

{"type":"thinking","content":"","done":true}

它只说明 thinking 阶段结束,不说明整条回答结束。

真正标记整条 assistant message 结束的是:

type == complete

所以前端应该以 complete 事件作为关闭 SSE 的信号,而不是看到 done=true 就结束。

stop 也作为一种事件写入 Redis 。用户点击停止时,后端写入 stop 事件。 SSE handler 读到后通知前端停止;生成 Worker / Agent 也可以轮询同一个 StreamManager ,发现 stop 后 cancel 。

这个设计天然适合多实例:停止请求打到任意实例,生成实例都能通过 Redis 看到 stop 事件。

断线续传:简单方案从 0 replay

断线续传有两种方式。

增强方案是前端携带 lastOffset :

GET /continue-stream?message_id=xxx&offset=128

但当前项目采用的是简单方案:

前端 SSE 断线
  -> 调 continue-stream
  -> 后端从 offset = 0 读取 Redis 中已有事件
  -> replay 历史事件
  -> 再继续拉新事件

优点是简单:

  • 前端不用维护 offset
  • 后端不用保存每个客户端的消费位点
  • 重连逻辑稳定

缺点是会重复发送历史事件。

所以前端需要做幂等处理,或者 replay 前清空当前未完成消息再重放。

Redis 不是最终消息存储

还有一个重要边界:

Redis 只保存临时事件流
messages 表才保存最终消息
生成过程中,后端一边把answer token写入 Redis,一边累积 final answer 。

complete 时:

finalAnswer 写入 messages 表
引用、Agent steps、耗时等元信息写入数据库
写入 complete 事件

停止时,也会保存已经生成的部分内容,并标记 message completed 或 stopped 。

这套设计的分工很明确:

Redis:
  实时推送
  replay
  stop 信号
  临时 TTL
Database:
  最终回答
  引用来源
  Agent steps
  长期事实

为什么不用 Redis Stream ?

因为当前需求里,单条回答事件量有限,只需要 SSE replay,不需要 consumer group,ack,这些复杂能力。Redis List 的 RPUSH/ LRANGE足够简单,也足够稳定。
结语:源码里真正值得学的是这些边界
对比可见,WeKnora 的 PDF、Excel 实现方案和 LatticeMind 架构路线截然不同,差异细节可参阅往期内容。DuckDB 承接 Excel 数据分析、pg_search 构建 PG-BM25 检索两处设计尤其亮眼。通用化工程方案,本质是在运行损耗、开发成本、资源开销与场景适配之间做取舍。相比直接摘抄源码,这套项目沉淀的落地思路,才是真正具备复用价值的精华,当然取舍本身也是门学问。

相关学习资料