Chico Notes
研究与调研

WeKnora 技术实现:从文档入库、Chunk 数据模型到混合检索

从源码解释 WeKnora 如何把异构文档转换为可检索、可重建、可被 RAG、Agent 与 Wiki 共用的知识资产。

持续修订的工程笔记

很多 RAG 示例都可以用一条很短的链路解释:

上传文档 → 切分 Chunk → 写入向量数据库 → 检索 Top-K → 交给大模型回答。

这条链路适合验证想法,却解释不了生产系统里真正困难的部分:原文件和索引谁才是事实来源?文档什么时候算可用?解析到一半失败如何恢复?用户编辑 Chunk 后,旧向量如何避免继续参与检索?多个知识库使用不同 Embedding 模型时能否一起查询?Redis 丢失以后,后台工作还能不能恢复?

WeKnora 值得研究,不是因为它接入了很多模型和向量库,而是因为它把这些控制面问题放进了同一套知识系统:文档生命周期、Parser 路由、Chunk 数据模型、检索融合、异步任务、Agent、Wiki、权限和审计。

研究基线:本文以 WeKnora v0.7.1 为版本基线,源码快照固定到 main 分支 commit 51b88bb,时间为 2026-08-05 UTC。项目更新较快,后续字段与流程可能继续变化。本文优先依据源码、迁移和官方文档,而不是只依据 README 的功能描述。

摘要

WeKnora 不只是带聊天界面的向量知识库。它把原始文件、文档状态、Chunk、检索索引和异步增强结果分层管理,并以此支持 Quick Q&A、ReAct Agent、知识图谱与 Wiki。本文从源码拆解这套数据模型、入库流程、混合检索和生产风险。

读者将学到什么

读完本文,你应该能够回答以下问题:

  • 一份 PDF 上传后,系统内部究竟生成了哪些数据;
  • KnowledgeBaseKnowledgeChunk 和检索索引分别负责什么;
  • 为什么 Chunk 应保存在业务数据库中,而不是只写进向量数据库;
  • 父子分块如何同时兼顾匹配精度和上下文完整性;
  • BM25 与向量召回如何通过 RRF 融合;
  • 文档可检索与全部后台任务完成为什么是两个状态;
  • 多模型、多向量库和多租户检索会引入哪些额外问题;
  • 一个生产级 RAG 系统应如何处理失败、重试、删除和重建。

问题背景:上传文档为什么不是 RAG 的终点

真实文档不是一组干净字符串。一份企业文档可能同时包含:

  • 标题层级和目录;
  • 普通段落、脚注和跨页引用;
  • 表格、图片、图注和公式;
  • 扫描页和错误文本层;
  • 页眉、页脚与多栏排版;
  • 用户后续修订的内容。

系统还要回答一些和向量相似度无关的问题:

  • 原始文件存在哪里;
  • 哪个 Parser 解析了它;
  • 当前使用哪套 Chunking 配置;
  • Chunk 已经写入,但索引是否完成;
  • 文档可以检索了,摘要和图谱是否仍在运行;
  • 用户修改正文后,索引是否追上当前 revision;
  • 删除文档时,正在运行的后台任务怎么办。

如果这些问题没有明确的数据模型,系统很容易进入一种危险状态:页面显示文档存在,数据库里也有记录,但实际参与检索的内容已经过期、残缺或重复。

上传成功不等于可以检索

一次文档导入至少包含以下动作:

保存原文件
→ 创建文档元数据
→ 解析正文、图片和表格
→ 切分 Chunk
→ 保存 Chunk
→ 调用 Embedding
→ 写向量或关键词索引
→ 生成摘要、问题、图谱或 Wiki

这些动作跨越关系数据库、对象存储、向量数据库、Redis 队列和外部模型 API,通常不处在同一个事务中。任何一步都可能失败。

假设一份 PDF 已经解析出 300 个 Chunk,但 Embedding Provider 在批量写入时触发限流。此时不能只返回一个“上传失败”:原文件已经存在,Chunk 可能已经写入,部分向量可能进入索引,后台还可能残留摘要任务。

生产系统因此需要:

  • 明确的状态机;
  • 幂等任务和补偿操作;
  • 可取消、可重试、可巡检的后台执行;
  • 可以从事实数据重新构建的派生索引。

核心心智模型:事实数据与检索投影

理解 WeKnora 最重要的一个视角,是把数据分成两类:

  1. 事实数据:应用运行时继续加工和恢复系统所依赖的基础数据;
  2. 派生投影:为了检索、摘要、图谱或 Wiki 等用途,从基础数据生成的表示。

最关键的关系是:

Chunk → Index

而不是:

Index = Chunk

向量索引服务于查询,但不应该反过来承担业务事实存储。

对象回答的问题丢失后的处理
原始文件用户最初上传了什么通常不可接受,应从备份恢复
Knowledge这份文档是什么,当前处于什么状态从关系库备份恢复
Chunk系统将文档理解成哪些内容单元可从原文件重建,但应持久化
向量/BM25 索引如何快速找到相关 Chunk从 Chunk 重建
摘要、图谱、Wiki如何以其他结构消费内容通常可以重新生成
Redis 运行态哪个 Worker 正在执行什么根据持久状态补发任务

这个模型带来一个直接结论:

向量索引应被视为可重建的查询投影,而不是知识系统唯一的数据源。


整体架构:Go 负责控制,Parser 与模型负责计算

WeKnora 的默认部署至少包含:

  • Web 前端与 Nginx;
  • Go 编写的主应用;
  • 独立的 DocReader 文档解析服务;
  • PostgreSQL / ParadeDB;
  • Redis 与 Asynq 后台任务;
  • 本地文件系统或对象存储;
  • Chat、Embedding、Rerank、VLM 与 ASR 模型;
  • 可选的外部向量数据库和 Neo4j 知识图谱。

v0.7.1 移除了旧的 Neo4j 对话记忆依赖,但知识图谱能力仍保留。两者不应混为一谈。

为什么 DocReader 要独立部署

文档解析和普通 Web API 的资源模型不同。解析 PDF 可能涉及文本层识别、页面布局、扫描页渲染、图片抽取、OCR 和表格重建,CPU、内存和执行时间都明显更高。

把 DocReader 拆成独立服务,可以:

  • 单独限制 CPU、内存和 Worker;
  • 独立升级 Python 解析依赖;
  • 避免 Parser 崩溃拖垮普通 API;
  • 为 gRPC 增加超时、TLS 和认证;
  • 根据文档压力独立扩缩容。

Parser 不是单一实现

当前 Parser Registry 包括 builtinsimpleweknoracloud、MinerU 和 PaddleOCR-VL 等实现。不同文件类型可以通过 ParserEngineRules 绑定不同引擎。

Parser 的目标不是直接写向量库,而是收敛成统一中间结构,让后续 Chunking、Embedding 和索引逻辑不依赖某个具体解析器。


核心数据模型

WeKnora 的知识数据不是一张 documents 表加一张 vectors 表,而是四层结构:

层级主要对象作用
空间层Tenant / Workspace用户、权限、配额、模型和基础设施隔离
知识库层KnowledgeBase保存处理策略与资源绑定
文档层Knowledge保存来源文件和处理生命周期
内容层Chunk / ChunkRevision保存正文、关系和编辑历史

KnowledgeBase:处理策略和基础设施边界

KnowledgeBase 不只是文档目录。它保存:

  • 知识库类型:documentfaqwiki
  • Chunk 大小、Overlap、父子分块和 Parser 路由;
  • Embedding、Summary、VLM 与 ASR 配置;
  • StorageBackend 和 VectorStore 绑定;
  • FAQ、问题生成、Wiki 与 Graph 配置;
  • Vector、Keyword、Wiki、Graph 四条索引管线开关。

当前 VectorStoreID 只允许创建时设置。原因是底层存储切换不是普通配置更新,而是数据迁移:目标索引需要先全量重建和验证,再切换绑定,最后清理旧索引。

Knowledge:一份文档的生命周期

Knowledge 保存来源、渠道、文件信息、对象存储路径、解析状态、错误和处理时间。它更接近“文档任务实体”,回答的是:

这是什么文档?
从哪里来?
属于哪个知识库?
文件保存在哪里?
当前处理到哪个阶段?
上次失败的原因是什么?

它还把内部 Metadata 与用户撰写的 CustomMetadata 分开,避免内部路径、任务字段和控制信息无意进入 LLM 上下文。

Chunk:检索、编辑和引用的共同单元

Chunk 不只是切分后的一段文本,还保存:

  • SourceContent:Parser 输出的原始正文;
  • ContentRevision:每次编辑或回滚递增;
  • IndexStatus:当前正文是否已经同步到检索后端;
  • StartAt / EndAt:原始文档位置;
  • PreChunkID / NextChunkID:邻接关系;
  • ParentChunkID:父子分块关系;
  • ContextHeader:Markdown 标题面包屑;
  • RelationChunks:关联内容;
  • ChunkType:文本、图片 OCR、表格摘要、FAQ、Wiki 页面等。

当用户修改 Chunk 时,数据库正文可能先更新,而索引仍是旧版本。WeKnora 在查询结果回查阶段会跳过 IndexStatus=processingfailed 的 Chunk,宁可暂时不返回,也不把旧向量和新正文拼成一个无法解释的结果。


工作原理:文档入库状态机

HTTP 上传请求不应同步等待所有步骤完成。更合理的过程是:接收文件、创建 Knowledge、保存原文件并投递任务,然后由 Worker 推进状态。

finalizing 为什么重要

WeKnora 区分:

processing
    核心解析或索引仍在执行

finalizing
    主索引已经可用,增强任务仍在执行

completed
    本次配置要求的任务均已进入终态

因此:

可检索,不等于所有后台处理已经结束。

如果向量写入成功后立刻标记 completed,后处理任务可能因为状态检查而跳过摘要、问题或图谱生成。finalizing 让用户可以先查询文档,同时系统继续完成非阻塞增强。

幂等重建与失败窗口

重新解析前,WeKnora 会清理旧 Chunk、旧检索数据和旧图谱,然后写入新结果。这能避免同一文档的多个版本同时参与检索。

但“先删旧数据,再写新数据”存在失败窗口:旧索引已经删除,新 Embedding 尚未完成,文档会暂时不可查。

高可用系统可以增加 generation

后台构建 generation=8
→ 校验 Chunk 和索引数量
→ 抽样检索验证
→ 原子切换 active_generation: 7 → 8
→ 延迟删除 generation=7

这是基于当前流程给出的工程增强建议,不代表 WeKnora 所有后端已经统一采用这种切换方式。


自适应 Chunking 与父子分块

固定长度切分只解决“输入不能无限长”,没有解决“什么是有意义的内容边界”。

WeKnora 当前支持:

  • legacy / recursive:传统递归分隔符切分;
  • heading:按标题结构切分;
  • heuristic:根据文档特征进行启发式切分;
  • auto:分析文档 Profile 后选择策略链。

自动模式不会盲目信任某一层输出。每个 Tier 生成 Chunk 后都要经过完整性检查;失败则回退到下一层,最终回到 Legacy Splitter。系统还会根据 Token Limit 估算字符预算,并限制过大的 Overlap,避免相邻 Chunk 接近重复。

Parent-Child 的目标不是“把 Chunk 做大”

父子分块分离了两个互相冲突的目标:

小 Child Chunk:提高匹配精度
大 Parent Chunk:提高生成上下文完整性

默认配置中,Parent 约为 4096 字符,Child 约为 384 字符,Child Overlap 约为 20%。Parent 存在数据库中但不写向量索引;真正参与匹配的是 Child。命中 Child 后,查询链路再回查 Parent。

ContextHeader 则把标题路径拼到 Embedding 输入中。数据库正文可能只有:

默认超时时间为 30 秒,可以通过配置文件覆盖。

索引文本可以是:

WeKnora 部署配置

## 网络与超时
### DocReader RPC

默认超时时间为 30 秒,可以通过配置文件覆盖。

索引文本为了匹配,Chunk 正文为了展示和生成,两者可以相关,但职责不同。


索引层:默认 PostgreSQL 如何同时承载 BM25 与向量检索

默认 PostgreSQL Retriever 使用 embeddings 表保存:

  • source_idchunk_id
  • knowledge_idknowledge_base_id
  • 用于检索的文本;
  • Embedding 维度;
  • halfvec 向量;
  • 是否启用和 Tag。

表上分别建立:

  • 基于 pg_search 的 BM25 索引;
  • 基于 pgvector 的 HNSW 向量索引。

“都放在 PostgreSQL”不代表只执行一次查询。应用层仍然会分别发起向量召回和关键词召回,再在上层融合。

正确但很慢:1024 维 HNSW 缺失

早期迁移只为部分向量维度创建了 HNSW 索引。当本地部署使用常见的 BGE-M3 1024 维向量时,查询仍然能得到正确结果,但可能退化为顺序扫描。后续迁移 000059_embeddings_hnsw_1024 专门补上 1024 维部分索引。

这类问题说明,生产监控不能只检查“结果是否正确”,还要检查:

查询计划是否命中预期索引
Embedding 维度是否有对应 HNSW
迁移是否覆盖当前模型
索引构建是否完成

混合检索:先保证可比较,再谈融合

WeKnora 的 Hybrid Search 不是简单并发调用向量检索和 BM25。它先解决四个更基础的问题:

  1. 本次查询包含哪些 KB、Knowledge、Tag 和 @Mention 范围;
  2. 调用者是否有权访问每一个目标 KB;
  3. 多个 KB 是否处于兼容的 Embedding 空间;
  4. 每个 KB 绑定到哪个 VectorStore,以及该 Store 属于哪个 Workspace。

源码用“模型名 + Endpoint”识别底层 Embedding 模型,而不只比较不同租户中的 Model ID。多个 KB 共享同一模型时,Query Embedding 只计算一次。随后按 (VectorStoreID, OwnerTenantID) 分组扇出,防止共享 KB 错用调用方自己的存储配置。

为什么使用 RRF

BM25、向量相似度和不同后端的绝对分数通常没有共同量纲。WeKnora 在两路结果同时存在时使用加权 Reciprocal Rank Fusion,倒数排名融合:

RRF(chunk)
  = vectorWeight  / (k + vectorRank)
  + keywordWeight / (k + keywordRank)

RRF 主要依赖排名位置,降低不同评分尺度之间的冲突。只有单路召回时,系统才保留原始分数并按 Chunk ID 去重。

检索结果不是最终上下文

检索后端先返回候选 Chunk ID,应用层再回查数据库并补充:

  • Parent Chunk;
  • 前后相邻 Chunk;
  • Relation Chunk;
  • 图片 OCR 或 Caption 对应的正文;
  • Knowledge 来源和自定义元数据。

更准确的链路是:

召回候选 ID
→ 回查当前 Chunk
→ 验证 enabled / index_status / 权限
→ 加载父级和邻近内容
→ 组装引用
→ 可选 Rerank
→ 交给 LLM

Quick Q&A 与 ReAct Agent 是两条不同链路

两者共用知识检索,但控制权不同。

维度Quick Q&AReAct Agent
流程理解 → 搜索 → 排序 → 生成LLM 在循环中决定是否调用工具
知识检索回答前固定阶段knowledge_search 是工具
工具范围主要是 KB 与 WebKB、Web、MCP、Skills、Sandbox
输出流式答案与引用Thinking、Tool Call、Result、Complete
上下文Top-K 和提示拼装Token Estimator 与 Memory Consolidator

Agent Engine 跨轮无状态。每轮开始前,调用方从数据库重建会话历史并作为 llmContext 传入;引擎不依赖进程本地的跨轮缓存。

这个边界使服务重启和水平扩容更容易,但要求消息层保存足够的执行事实:历史内容、知识引用、工具步骤、模型信息和当时真正发送给 LLM 的上下文。


最小实现示例

下面的代码不是 WeKnora 源码复制,而是把它的核心设计压缩成一个最小 Go 示例:先保存事实数据,再构建可重建索引;查询时并行召回、RRF 融合,最后根据 ID 回查正文。

package knowledge

import (
    "context"
    "errors"
    "sort"
)

type Knowledge struct {
    ID        string
    KBID      string
    Title     string
    Status    string // pending, processing, finalizing, completed, failed
    Generation int
}

type Chunk struct {
    ID          string
    KnowledgeID string
    KBID        string
    Content     string
    Revision    int
    IndexStatus string // ready, processing, failed
    ParentID    string
}

type IndexRecord struct {
    ChunkID     string
    KnowledgeID string
    KBID        string
    Revision    int
    Content     string
    Embedding   []float32
}

type FactStore interface {
    ReplaceChunks(ctx context.Context, knowledgeID string, chunks []Chunk) error
    LoadChunks(ctx context.Context, ids []string) (map[string]Chunk, error)
    UpdateKnowledgeStatus(ctx context.Context, id, status string) error
}

type SearchIndex interface {
    ReplaceKnowledge(ctx context.Context, knowledgeID string, records []IndexRecord) error
    VectorSearch(ctx context.Context, kbIDs []string, query []float32, topK int) ([]string, error)
    KeywordSearch(ctx context.Context, kbIDs []string, query string, topK int) ([]string, error)
}

type Embedder interface {
    Embed(ctx context.Context, texts []string) ([][]float32, error)
}

type Service struct {
    facts FactStore
    index SearchIndex
    embed Embedder
}

func (s *Service) ProcessDocument(
    ctx context.Context,
    k Knowledge,
    chunks []Chunk,
) error {
    if err := s.facts.UpdateKnowledgeStatus(ctx, k.ID, "processing"); err != nil {
        return err
    }

    // Chunk 是事实数据,先持久化。
    if err := s.facts.ReplaceChunks(ctx, k.ID, chunks); err != nil {
        _ = s.facts.UpdateKnowledgeStatus(ctx, k.ID, "failed")
        return err
    }

    texts := make([]string, len(chunks))
    for i, c := range chunks {
        texts[i] = k.Title + "\n\n" + c.Content
    }

    vectors, err := s.embed.Embed(ctx, texts)
    if err != nil {
        _ = s.facts.UpdateKnowledgeStatus(ctx, k.ID, "failed")
        return err
    }
    if len(vectors) != len(chunks) {
        return errors.New("embedding result count mismatch")
    }

    records := make([]IndexRecord, len(chunks))
    for i, c := range chunks {
        records[i] = IndexRecord{
            ChunkID: c.ID, KnowledgeID: c.KnowledgeID, KBID: c.KBID,
            Revision: c.Revision, Content: texts[i], Embedding: vectors[i],
        }
    }

    // 索引是派生投影,应支持按 Knowledge 整体替换。
    if err := s.index.ReplaceKnowledge(ctx, k.ID, records); err != nil {
        _ = s.facts.UpdateKnowledgeStatus(ctx, k.ID, "failed")
        return err
    }

    // 主索引可用,增强任务可以继续异步执行。
    return s.facts.UpdateKnowledgeStatus(ctx, k.ID, "finalizing")
}

type RankedID struct {
    ID    string
    Score float64
}

func weightedRRF(vectorIDs, keywordIDs []string, k int, vw, kw float64) []RankedID {
    scores := map[string]float64{}
    for rank, id := range vectorIDs {
        scores[id] += vw / float64(k+rank+1)
    }
    for rank, id := range keywordIDs {
        scores[id] += kw / float64(k+rank+1)
    }

    out := make([]RankedID, 0, len(scores))
    for id, score := range scores {
        out = append(out, RankedID{ID: id, Score: score})
    }
    sort.Slice(out, func(i, j int) bool { return out[i].Score > out[j].Score })
    return out
}

这个最小实现仍省略了很多生产细节:事务 Outbox、Generation、权限 Scope、批量限流、模型分组、索引补偿、Parent 回查、重试和 Trace。但它保留了最重要的边界:

Fact Store 保存当前事实
Search Index 保存可重建投影

生产环境中的失败模式

1. 表格合成表头会破坏原始 Offset

2026-08-05 的 commit 51b88bb 修复了一个很典型的问题。

为了让每个表格 Chunk 能独立理解,分块器可能为后续 Chunk 重复添加表头。但合成表头并不存在于原始文件的 StartAt / EndAt 中。如果摘要重建仍完全依赖原始 Offset,可能覆盖或丢失表格行。

修复改为根据真实文本重叠合并表格 Chunk,而不是只按 Offset 切片。

这个案例说明:

一旦 Chunk 被补标题、补表头、OCR、人工编辑或上下文增强,原始字符位置就不再足以描述当前内容。

2. Embedding 维度不同,却被放进同一次查询

不同模型、Endpoint、归一化方式和距离度量产生的向量不能直接比较。即使维度相同,也不代表处于同一语义空间。

多 KB 查询必须先验证模型身份,再决定是否共享 Query Embedding。不能只把多个向量库结果拼起来按原始 Score 排序。

3. Chunk 已修改,旧索引仍在返回

人工编辑通常先写数据库,再异步更新检索索引。这个窗口里,向量库返回的是旧 revision。

应至少保存:

chunk.content_revision
index.revision
chunk.index_status

查询回查时验证版本;Repair Job 定期扫描漏写、旧写和孤儿索引。

4. 增加 Worker 后,队列反而更堵

Worker 并发只是任务准入预算,不会扩大模型 RPM/TPM、DocReader CPU、数据库连接池或向量库写入能力。

如果模型限流器等待时间持续上升,增加 Worker 只会制造更多等待者。应同时观察队列最老任务年龄、Worker 利用率、模型并发、Provider 限流、DocReader 资源和数据库连接。

5. Redis 丢失导致文档永久停在 processing

Redis 适合回答“当前由谁执行”,不适合作为“最终还有什么必须完成”的唯一来源。

长期任务应在数据库中保留 Outbox / Pending Ops、Attempt、Lease、Retry Count 和 Dead Letter。服务启动或巡检时,根据 Knowledge 状态补发丢失任务。

6. URL 导入扩大 SSRF 攻击面

URL 导入、数据源同步、Web Fetch、远程 Parser、模型 Endpoint 和对象存储下载都会触发服务端出站请求。

生产系统需要统一的安全 HTTP Transport:

  • 拒绝内网和云 Metadata 地址;
  • 重定向后重新校验目标;
  • 限制协议、端口和响应大小;
  • 设置 DNS、连接和读取超时;
  • 对必要内部地址使用明确 Allowlist;
  • 日志中脱敏 Token 与密钥。

WeKnora v0.7.0v0.7.1 都包含大量 SSRF 与凭据泄露加固,这说明它不是外围问题,而是知识摄取系统的核心安全边界。

7. 删除和后台任务发生竞态

用户删除 Knowledge 时,Parser、Embedding 或 Graph 任务可能仍在运行。只删数据库记录不够:旧 Worker 可能随后把 Chunk 或索引重新写回来。

常见策略是:

先将 Knowledge 标记为 deleting
→ 后续阶段入口反复检查状态
→ 取消或忽略排队任务
→ 清理 Chunk、索引、图和原文件
→ 最后完成删除

方案比较与取舍

维度WeKnora 当前思路更简单的实现取舍
事实数据关系库保存 Knowledge/Chunk向量库直接保存正文前者更易重建和治理,组件更多
原始资产独立对象存储只保留解析文本前者可重新解析,成本更高
关键词检索BM25 独立召回只做向量检索混合召回更稳,但需要融合和评测
分块自适应 + Parent-Child固定长度质量更好,调试复杂度更高
文档状态状态机 + 异步队列请求内同步完成可扩展且可恢复,状态协调更复杂
存储绑定每 KB 绑定实例全局单一存储灵活隔离,但迁移和路由更复杂
多 KB 查询模型校验 + Store Fan-out合并所有结果更正确,但实现和观测成本更高
Agent作为上层工具消费者检索逻辑写死在 Agent边界清晰,但工具协议需要治理
索引升级清理重建,可增强为 Generation原地覆盖前者可恢复,重建期间占用双份资源

不要为了“企业级”复制全部组件

对于单人使用、文档量较小、更新频率低的知识库,PostgreSQL + pgvector + 本地存储可能已经足够。只有当你确实遇到以下问题时,才需要引入更复杂的多 Store、Wiki、Graph 和独立 Worker 治理:

  • 不同业务要求物理隔离;
  • 索引量或并发超过单库能力;
  • 文档持续同步和频繁更新;
  • 需要可审计的多人协作;
  • 必须支持复杂的 Agent 工具执行;
  • 需要在解析失败后自动恢复。

实践检查清单

数据与所有权

  • 原文件是否有不可替代的持久存储和备份;
  • Knowledge、Chunk 和索引是否有稳定 ID;
  • 是否明确了关系库与向量库谁是 Source of Truth;
  • Chunk 是否保留 SourceContent、Revision 和 IndexStatus;
  • 用户元数据是否与内部 ingestion metadata 分开。

Parser 与 Chunking

  • 是否按文件类型选择 Parser,而不是全局单一引擎;
  • 是否保留标题路径、表格上下文和图片来源;
  • Parent 是否只用于上下文,Child 是否用于召回;
  • Overlap 是否过大,导致候选高度重复;
  • Parser 评估是否延伸到最终 Retrieval,而不只看文本输出。

索引与检索

  • Embedding 模型、维度、归一化和距离度量是否记录;
  • 每个实际向量维度是否存在匹配的 ANN 索引;
  • 多 KB 查询是否先校验 Embedding 空间;
  • 是否复用同模型 Query Embedding;
  • BM25 和 Vector 是否使用 RRF 等稳定方式融合;
  • Rerank 失败是否能回退;
  • 查询结果是否回查当前 Chunk、权限和 revision。

任务与一致性

  • 每个任务是否有幂等键、Attempt 和 Stage;
  • Redis 丢失后是否能从数据库恢复任务;
  • 删除、取消和重新解析是否会阻止旧 Worker 回写;
  • 是否有 Dead Letter 和人工重放入口;
  • 是否区分“可检索”和“所有增强任务完成”;
  • 是否有 Repair / Reindex Job 检查孤儿与过期索引。

安全与可观测

  • 所有服务端出站请求是否经过统一 SSRF 防护;
  • 模型、存储和向量库密钥是否加密并在 API 中脱敏;
  • 向量查询 Scope 是否由服务端身份生成;
  • 是否记录 Parser、Chunking、Embedding、Retrieve、Rerank 和 Agent Span;
  • 是否能看到队列最老任务年龄,而不只看任务数量;
  • 是否对模型并发、RPM、TPM 和成本进行独立治理。

总结

WeKnora 最值得研究的部分不是模型接入数量,而是它承认知识系统会不断失败、更新和迁移,并为这些变化保留明确状态:

  • 原文件属于哪个存储实例;
  • 文档是否已经可检索;
  • 后台还有多少子任务;
  • Chunk 当前是哪个 revision;
  • 索引是否追上当前正文;
  • 多个 KB 是否处于同一个 Embedding 空间;
  • 某条检索或 Rerank 链路失败后如何降级;
  • Worker 崩溃后哪些任务仍必须执行;
  • 向量索引损坏后从哪里重建。

一句话总结:

保存好关系数据库中的业务事实和对象存储中的原始资产,把向量、BM25、图谱和 Wiki 设计成可重建投影;再用状态机、Revision、持久任务和分阶段评测管理它们之间的不一致。


参考资料

图片清单

weknora-document-to-knowledge-cover.webp

  • 用途:文章原创封面;
  • 尺寸:16
  • Alt 文本:异构文档经过解析、分块和索引,转换为可供检索、Agent 与 Wiki 使用的知识网络;
  • 生成提示词16:9 editorial technology illustration, heterogeneous documents flowing through parsing layers into modular knowledge blocks, vector nodes and structured connections, subtle database and retrieval motifs, calm blue-gray and muted green palette, precise technical editorial style, generous negative space, no text, no logo, no brand marks
  • 状态:待生成并加入博客静态资源。

weknora-knowledge-pipeline.mmd

  • 用途:事实数据与派生投影心智模型;
  • Alt 文本:原始文件转换为 Knowledge 和 Chunk,再派生向量、BM25、摘要、图谱与 Wiki。

weknora-system-architecture.mmd

  • 用途:系统运行架构;
  • Alt 文本:客户端、Go App、DocReader、任务队列、数据库、对象存储、模型和检索后端之间的关系。

weknora-data-model.mmd

  • 用途:核心数据模型;
  • Alt 文本:Workspace、KnowledgeBase、Knowledge、Chunk、Revision 与 Retrieval Index 的关系。

weknora-ingestion-sequence.mmd

  • 用途:文档入库时序;
  • Alt 文本:文档从上传、解析、分块、索引到异步增强和完成的调用过程。

weknora-processing-state.mmd

  • 用途:Knowledge 状态机;
  • Alt 文本:pending、processing、finalizing、completed、failed、cancelled 和 deleting 之间的转换。

weknora-hybrid-search.mmd

  • 用途:混合检索流程;
  • Alt 文本:查询经过权限 Scope、Embedding 复用、多 Store 扇出、Vector/BM25、RRF 和上下文补全。

讨论

继续讨论这篇笔记

有问题、补充案例或不同观点,可以通过 GitHub Discussions 继续交流。

On this page

摘要读者将学到什么问题背景:上传文档为什么不是 RAG 的终点上传成功不等于可以检索核心心智模型:事实数据与检索投影整体架构:Go 负责控制,Parser 与模型负责计算为什么 DocReader 要独立部署Parser 不是单一实现核心数据模型KnowledgeBase:处理策略和基础设施边界Knowledge:一份文档的生命周期Chunk:检索、编辑和引用的共同单元工作原理:文档入库状态机finalizing 为什么重要幂等重建与失败窗口自适应 Chunking 与父子分块Parent-Child 的目标不是“把 Chunk 做大”索引层:默认 PostgreSQL 如何同时承载 BM25 与向量检索正确但很慢:1024 维 HNSW 缺失混合检索:先保证可比较,再谈融合为什么使用 RRF检索结果不是最终上下文Quick Q&A 与 ReAct Agent 是两条不同链路最小实现示例生产环境中的失败模式1. 表格合成表头会破坏原始 Offset2. Embedding 维度不同,却被放进同一次查询3. Chunk 已修改,旧索引仍在返回4. 增加 Worker 后,队列反而更堵5. Redis 丢失导致文档永久停在 processing6. URL 导入扩大 SSRF 攻击面7. 删除和后台任务发生竞态方案比较与取舍不要为了“企业级”复制全部组件实践检查清单数据与所有权Parser 与 Chunking索引与检索任务与一致性安全与可观测总结参考资料图片清单weknora-document-to-knowledge-cover.webpweknora-knowledge-pipeline.mmdweknora-system-architecture.mmdweknora-data-model.mmdweknora-ingestion-sequence.mmdweknora-processing-state.mmdweknora-hybrid-search.mmd