本文档基于字节跳动(扣子) Coze Studio 实际源码分析,展示了企业级 RAG 系统中文档入库处理的完整实现方案。
🚀项目地址:https://github.com/coze-dev/coze-studio
📋 目录
一、功能概述
1.1 文档入库流程
在 RAG(检索增强生成)系统中,文档入库是将用户上传的文档转换为可检索的知识片段的过程。这个过程包括:
- 文档解析:提取文档中的文本内容、表格数据、图片信息
- 智能分块:将长文档切分成适合检索的小块,保持语义完整性
- 向量化存储:将文本转换为向量,支持语义检索
- 索引建立:在向量数据库和搜索引擎中建立索引
1.2 支持的功能特性
📄 多格式文档支持
- PDF文档:自动提取文本内容和表格数据
- Word文档:保持格式和结构信息
- Excel表格:结构化数据存储,支持SQL查询
- 图片文件:OCR识别,自动提取文字内容
- 文本文件:直接文本处理
🔄 智能处理能力
- 自动分块:根据语义边界智能切分文档
- 表格识别:保持表格的行列关系
- 图片处理:OCR文字识别和图片描述生成
- 元数据提取:自动提取文档标题、作者等信息
⚡ 高性能处理
- 异步处理:文档上传不阻塞用户操作
- 批量处理:支持批量上传多个文档
- 进度跟踪:实时显示处理进度
- 错误重试:自动重试失败的处理任务
二、整体流程
2.1 文档入库流程
用户上传文档
↓
参数验证和预处理
↓
创建文档记录(数据库)
↓
发送异步索引任务(消息队列)
↓
文档解析和分块
↓
向量化处理
↓
存储到向量数据库和ES
↓
更新文档状态
三、技术架构
3.1 核心服务
知识库服务 (Knowledge Service)
- 功能:管理知识库和文档的生命周期
- 职责:文档CRUD、状态管理、进度跟踪
- 源码:
backend/domain/knowledge/service/knowledge.go
文档处理器 (Document Processor)
- 功能:处理不同类型的文档
- 职责:文档解析、分块、向量化
- 源码:
backend/domain/knowledge/processor/
存储管理器 (Storage Manager)
- 功能:管理多种存储后端
- 职责:向量存储、全文索引、结构化存储
- 源码:
backend/infra/contract/document/searchstore/
3.2 数据流
用户上传 → API验证 → 创建记录 → 发送任务 → 异步处理 → 存储完成
↓ ↓ ↓ ↓ ↓ ↓
前端 网关 数据库 消息队列 处理器 存储系统
3.3 异步处理流程
核心思想: 文档入库采用异步处理模式,通过消息队列解耦,提高系统性能和可靠性
完整的异步调用链路:
- 文档创建 →
CreateDocument()
方法 - 处理器处理 →
DocProcessor.Indexing()
方法 - 发送事件 → 发送
EventTypeIndexDocuments
事件到消息队列 - 消息消费 →
HandleMessage()
接收消息 - 事件分发 → 根据事件类型调用对应处理方法
- 文档处理 →
indexDocument()
处理单个文档 - 分块存储 → 解析文档并存储到向量数据库
异步处理优势:
- 性能提升:文档上传不阻塞用户操作
- 可靠性:消息队列提供重试机制
- 解耦设计:文档创建和索引处理分离
- 并发处理:支持多个文档同时处理
- 错误隔离:单个文档处理失败不影响其他文档
四、源码深度分析
4.1 服务入口 - CreateDocument
CreateDocument 方法的作用:
- 文档接收入口:接收用户上传的文档请求
- 参数验证:检查请求参数的合法性
- 文档预处理:将文档URL转换为存储URI,下载文档内容
- 创建处理器:根据文档类型创建对应的处理器
- 同步处理:执行文档的同步处理步骤(存储、数据库操作)
- 触发异步任务:通过
Indexing()
方法发送消息到队列,启动异步索引
关键点:
- 快速响应:立即返回文档信息,不等待索引完成
- 同步+异步分离:文档元数据同步保存,内容解析异步处理
- 错误处理:每个步骤都有详细的错误处理机制
- 事务安全:数据库操作使用事务保证一致性
如何触发异步任务:
- 调用
Indexing()
方法:在CreateDocument
的最后阶段调用 - 发送消息到队列:
Indexing()
方法会创建索引事件并发送到消息队列 - 异步消费处理:消息队列的消费者会接收消息并调用
indexDocument
方法 - 文档解析存储:
indexDocument
方法执行实际的文档解析和向量化存储
// backend/domain/knowledge/service/knowledge.go
// 创建文档的入口方法 - 用户上传文档时的第一个处理点
func (k *knowledgeSVC) CreateDocument(ctx context.Context, request *CreateDocumentRequest) (response *CreateDocumentResponse, err error) {
// 1. 参数验证
if err = k.checkRequest(request); err != nil {
return nil, errorx.New(errno.ErrKnowledgeInvalidParamCode, errorx.KV("msg", err.Error()))
}
// 2. 文档预处理 - URL转URI(下载文档到存储系统)
if err = k.documentsURL2URI(ctx, request.Documents); err != nil {
return nil, errorx.New(errno.ErrKnowledgeDownloadFailedCode, errorx.KV("msg", err.Error()))
}
// 3. 提取用户和空间信息
userID := request.Documents[0].CreatorID
spaceID := request.Documents[0].SpaceID
documentSource := request.Documents[0].Source
// 4. 创建文档处理器 - 这是核心的处理组件
docProcessor := impl.NewDocProcessor(ctx, &impl.DocProcessorConfig{
UserID: userID,
SpaceID: spaceID,
DocumentSource: documentSource,
Documents: request.Documents,
KnowledgeRepo: k.knowledgeRepo,
DocumentRepo: k.documentRepo,
SliceRepo: k.sliceRepo,
Idgen: k.idgen,
Producer: k.producer, // 消息队列生产者
ParseManager: k.parseManager, // 文档解析管理器
Storage: k.storage, // 存储系统
Rdb: k.rdb, // 关系数据库
})
// 5. 执行文档处理流程
// 5.1 前置处理 - 上传文件到存储系统等
err = docProcessor.BeforeCreate()
if err != nil {
return nil, err
}
// 5.2 构建数据库模型 - 准备要保存的数据结构
err = docProcessor.BuildDBModel()
if err != nil {
return nil, err
}
// 5.3 插入数据库 - 保存文档元数据到MySQL
err = docProcessor.InsertDBModel()
if err != nil {
return nil, err
}
// 5.4 发起异步索引任务 - 关键步骤!触发后续的异步处理
err = docProcessor.Indexing()
if err != nil {
return nil, err
}
// 6. 返回处理后的文档信息
docs := docProcessor.GetResp()
return &CreateDocumentResponse{
Documents: docs,
}, nil
}
4.2 异步处理流程
4.2.1 事件发送流程
// backend/domain/knowledge/processor/impl/base.go
// 文档处理器的Indexing方法 - 这是CreateDocument触发异步任务的关键方法
func (p *baseDocProcessor) Indexing() error {
// 1. 创建索引文档事件 - 包含要处理的文档信息
event := events.NewIndexDocumentsEvent(p.Documents[0].KnowledgeID, p.Documents)
// 2. 序列化事件为JSON格式
body, err := sonic.Marshal(event)
if err != nil {
return errorx.New(errno.ErrKnowledgeParseJSONCode, errorx.KV("msg", err.Error()))
}
// 3. 发送到消息队列 - 这一步触发异步处理
if err = p.producer.Send(p.ctx, body); err != nil {
logs.CtxErrorf(p.ctx, "send message failed, err: %v", err)
return errorx.New(errno.ErrKnowledgeMQSendFailCode, errorx.KV("msg", err.Error()))
}
return nil
}
关键作用:
- 解耦设计:文档创建和索引处理完全分离
- 快速响应:用户上传文档后立即返回,不等待处理完成
- 可靠性:消息队列提供持久化和重试机制
- 并发处理:支持多个文档同时处理
4.2.2 消息队列调用流程
// backend/domain/knowledge/service/event_handle.go
// 消息处理器 - 接收并处理各种知识库事件
func (k *knowledgeSVC) HandleMessage(ctx context.Context, msg *eventbus.Message) (err error) {
// 1. 解析消息内容
event := &entity.Event{}
if err = sonic.Unmarshal(msg.Body, event); err != nil {
return errorx.New(errno.ErrKnowledgeParseJSONCode, errorx.KV("msg", fmt.Sprintf("unmarshal event failed, err: %v", err)))
}
// 2. 根据事件类型分发处理
switch event.Type {
case entity.EventTypeIndexDocuments:
// 批量文档索引事件 - 来自CreateDocument的Indexing()方法
if err = k.indexDocuments(ctx, event); err != nil {
return err
}
case entity.EventTypeIndexDocument:
// 单个文档索引事件 - 调用indexDocument方法
if err = k.indexDocument(ctx, event); err != nil {
return err
}
case entity.EventTypeIndexSlice:
// 单个分块索引事件
if err = k.indexSlice(ctx, event); err != nil {
return err
}
case entity.EventTypeDeleteKnowledgeData:
// 删除知识库数据事件
err = k.deleteKnowledgeDataEventHandler(ctx, event)
case entity.EventTypeDocumentReview:
// 文档审核事件
if err = k.documentReviewEventHandler(ctx, event); err != nil {
return err
}
default:
return errorx.New(errno.ErrKnowledgeNonRetryableCode, errorx.KV("reason", fmt.Sprintf("unknown event type=%s", event.Type)))
}
return nil
}
4.2.3 批量处理流程
// backend/domain/knowledge/service/event_handle.go
// 批量文档索引处理 - 将批量事件拆分为单个文档事件
func (k *knowledgeSVC) indexDocuments(ctx context.Context, event *entity.Event) (err error) {
if len(event.Documents) == 0 {
logs.CtxWarnf(ctx, "[indexDocuments] documents not provided")
return nil
}
// 1. 遍历每个文档
for i := range event.Documents {
doc := event.Documents[i]
if doc == nil {
logs.CtxWarnf(ctx, "[indexDocuments] document not provided")
continue
}
// 2. 为每个文档创建单独的索引事件
e := events.NewIndexDocumentEvent(doc.KnowledgeID, doc)
msgData, err := sonic.Marshal(e)
if err != nil {
logs.CtxErrorf(ctx, "[indexDocuments] marshal event failed, err: %v", err)
return errorx.New(errno.ErrKnowledgeParseJSONCode, errorx.KV("msg", fmt.Sprintf("marshal event failed, err: %v", err)))
}
// 3. 发送单个文档索引事件 - 使用分片键确保同一知识库的文档顺序处理
err = k.producer.Send(ctx, msgData, eventbus.WithShardingKey(strconv.FormatInt(doc.KnowledgeID, 10)))
if err != nil {
logs.CtxErrorf(ctx, "[indexDocuments] send message failed, err: %v", err)
return errorx.New(errno.ErrKnowledgeMQSendFailCode, errorx.KV("msg", fmt.Sprintf("send message failed, err: %v", err)))
}
}
return nil
}
4.3 核心文档处理 - indexDocument
// backend/domain/knowledge/service/event_handle.go
// 文档解析和分块的核心流程(来自indexDocument方法)
func (k *knowledgeSVC) indexDocument(ctx context.Context, event *entity.Event) (err error) {
doc := event.Document
if doc == nil {
return errorx.New(errno.ErrKnowledgeNonRetryableCode, errorx.KV("reason", "[indexDocument] document not provided"))
}
// 1. 从存储获取文档内容
bodyBytes, err := k.storage.GetObject(ctx, doc.URI)
if err != nil {
return errorx.New(errno.ErrKnowledgeGetObjectFailCode, errorx.KV("msg", fmt.Sprintf("get object failed, err: %v", err)))
}
// 2. 获取对应的解析器
docParser, err := k.parseManager.GetParser(convert.DocumentToParseConfig(doc))
if err != nil {
return errorx.New(errno.ErrKnowledgeGetParserFailCode, errorx.KV("msg", fmt.Sprintf("get parser failed, err: %v", err)))
}
// 3. 解析文档内容
slices, err := docParser.Parse(ctx, bytes.NewReader(bodyBytes))
if err != nil {
return errorx.New(errno.ErrKnowledgeParseFailCode, errorx.KV("msg", fmt.Sprintf("parse document failed, err: %v", err)))
}
// 4. 生成分块ID并保存到数据库
sliceIDs := k.idgen.GenIDs(len(slices))
sliceEntities := make([]*entity.KnowledgeDocumentSlice, len(slices))
for i, slice := range slices {
sliceEntities[i] = &entity.KnowledgeDocumentSlice{
ID: sliceIDs[i],
DocumentID: doc.ID,
Content: slice.Content,
MetaData: slice.MetaData,
Status: knowledge.KnowledgeStatusInit,
}
}
// 5. 批量保存分块到数据库
if err := k.sliceRepo.BatchCreate(ctx, sliceEntities); err != nil {
return errorx.New(errno.ErrKnowledgeCreateSliceFailCode, errorx.KV("msg", fmt.Sprintf("create slices failed, err: %v", err)))
}
// 6. 存储到向量数据库和ES
for _, slice := range sliceEntities {
if err := k.indexSlice(ctx, slice); err != nil {
return errorx.New(errno.ErrKnowledgeIndexSliceFailCode, errorx.KV("msg", fmt.Sprintf("index slice failed, err: %v", err)))
}
}
// 7. 更新文档状态
if err := k.documentRepo.SetStatus(ctx, doc.ID, knowledge.KnowledgeStatusSuccess); err != nil {
return errorx.New(errno.ErrKnowledgeUpdateStatusFailCode, errorx.KV("msg", fmt.Sprintf("update document status failed, err: %v", err)))
}
return nil
}
完整处理流程:
- 获取文档:从存储中获取文档内容
- 选择解析器:根据文档类型选择合适的解析器
- 解析内容:使用解析器解析文档内容
- 生成分块:将解析结果分割成多个分块
- 保存分块:将分块保存到数据库
- 向量化存储:将分块存储到向量数据库和ES
- 状态更新:更新文档处理状态
处理后的用途: 所有文档最终都会:
- 转换为结构化的分块数据
- 存储在向量数据库支持语义检索
- 建立全文索引支持关键词搜索
- 保持原始文档的结构和关系
4.4 文档处理器架构
文档处理器采用接口设计,支持不同类型的文档处理:
// backend/domain/knowledge/processor/interface.go
type DocProcessor interface {
BeforeCreate() error // 前置处理:获取数据源
BuildDBModel() error // 构建模型:创建数据库记录
InsertDBModel() error // 插入数据:保存到数据库
Indexing() error // 发起索引:发送异步任务
GetResp() []*entity.Document // 返回结果:获取处理后的文档
}
处理器类型:
- 基础处理器:处理普通文档(PDF、Word等)
- 表格处理器:处理Excel表格数据
- 自定义处理器:处理外部数据源
4.5 基础处理器实现
// backend/domain/knowledge/processor/impl/base.go
type baseDocProcessor struct {
ctx context.Context
UserID int64
SpaceID int64
Documents []*entity.Document
documentSource *entity.DocumentSource
// 数据库模型
TableName string
docModels []*model.KnowledgeDocument
// 依赖服务
storage storage.Storage
knowledgeRepo repository.KnowledgeRepo
documentRepo repository.KnowledgeDocumentRepo
sliceRepo repository.KnowledgeDocumentSliceRepo
idgen idgen.IDGenerator
rdb rdb.RDB
producer eventbus.Producer
parseManager parser.Manager
}
步骤1:前置处理 (BeforeCreate)
func (p *baseDocProcessor) BeforeCreate() error {
// 对于本地文档,无需特殊处理
// 对于自定义数据源,需要从外部系统拉取数据
return nil
}
步骤2:构建数据库模型 (BuildDBModel)
func (p *baseDocProcessor) BuildDBModel() error {
// 批量生成文档ID,提高性能
ids, err := p.idgen.GenMultiIDs(p.ctx, len(p.Documents))
if err != nil {
return errorx.New(errno.ErrKnowledgeIDGenCode)
}
// 为每个文档构建数据库模型
for i := range p.Documents {
docModel := &model.KnowledgeDocument{
ID: ids[i],
KnowledgeID: p.Documents[i].KnowledgeID,
Name: p.Documents[i].Name,
FileExtension: string(p.Documents[i].FileExtension),
URI: p.Documents[i].URI,
DocumentType: int32(p.Documents[i].Type),
CreatorID: p.UserID,
SpaceID: p.SpaceID,
SourceType: int32(p.Documents[i].Source),
Status: int32(knowledge.KnowledgeStatusInit), // 初始状态
ParseRule: &model.DocumentParseRule{
ParsingStrategy: p.Documents[i].ParsingStrategy,
ChunkingStrategy: p.Documents[i].ChunkingStrategy,
},
CreatedAt: time.Now().UnixMilli(),
UpdatedAt: time.Now().UnixMilli(),
}
p.Documents[i].ID = docModel.ID
p.docModels = append(p.docModels, docModel)
}
return nil
}
步骤3:数据库插入 (InsertDBModel)
func (p *baseDocProcessor) InsertDBModel() (err error) {
ctx := p.ctx
// 对于表格类型,创建数据库表
if !isTableAppend(p.Documents) {
err = p.createTable()
if err != nil {
return errorx.New(errno.ErrKnowledgeCrossDomainCode, errorx.KV("msg", err.Error()))
}
}
// 使用事务确保数据一致性
tx, err := p.knowledgeRepo.InitTx()
if err != nil {
return errorx.New(errno.ErrKnowledgeDBCode, errorx.KV("msg", err.Error()))
}
// 完善的错误处理和回滚机制
defer func() {
if e := recover(); e != nil {
err = errorx.New(errno.ErrKnowledgeSystemCode, errorx.KVf("msg", "panic: %v", e))
tx.Rollback()
return
}
if err != nil {
tx.Rollback()
// 清理已创建的资源
if p.TableName != "" {
p.deleteTable()
}
} else {
tx.Commit()
}
}()
// 批量插入文档记录
err = p.documentRepo.CreateWithTx(ctx, tx, p.docModels)
if err != nil {
return errorx.New(errno.ErrKnowledgeDBCode, errorx.KV("msg", err.Error()))
}
// 更新知识库的最后修改时间
err = p.knowledgeRepo.UpdateWithTx(ctx, tx, p.Documents[0].KnowledgeID, map[string]interface{}{
"updated_at": time.Now().UnixMilli(),
})
if err != nil {
return errorx.New(errno.ErrKnowledgeDBCode, errorx.KV("msg", err.Error()))
}
return nil
}
步骤4:发起异步索引 (Indexing)
func (p *baseDocProcessor) Indexing() error {
// 创建索引事件
event := events.NewIndexDocumentsEvent(p.Documents[0].KnowledgeID, p.Documents)
body, err := sonic.Marshal(event)
if err != nil {
return errorx.New(errno.ErrKnowledgeParseJSONCode, errorx.KV("msg", err.Error()))
}
// 通过消息队列发送异步任务
if err = p.producer.Send(p.ctx, body); err != nil {
logs.CtxErrorf(p.ctx, "send message failed, err: %v", err)
return errorx.New(errno.ErrKnowledgeMQSendFailCode, errorx.KV("msg", err.Error()))
}
return nil
}
五、文档拆分与处理详解
5.1 解析器选择
Coze Studio 采用插件化解析器架构,支持多种文档格式的智能解析:
// backend/infra/impl/document/parser/builtin/manager.go
func (m *manager) GetParser(config *parser.Config) (parser.Parser, error) {
var pFn parseFn
// 根据文件扩展名选择解析器
switch config.FileExtension {
case parser.FileExtensionPDF:
pFn = parseByPython(config, m.storage, m.ocr, goutil.GetPython3Path(), goutil.GetPythonFilePath("parse_pdf.py"))
case parser.FileExtensionTXT:
pFn = parseText(config)
case parser.FileExtensionMarkdown:
pFn = parseMarkdown(config, m.storage, m.ocr)
case parser.FileExtensionDocx:
pFn = parseByPython(config, m.storage, m.ocr, goutil.GetPython3Path(), goutil.GetPythonFilePath("parse_docx.py"))
case parser.FileExtensionCSV:
pFn = parseCSV(config)
case parser.FileExtensionXLSX:
pFn = parseXLSX(config)
case parser.FileExtensionJSON:
pFn = parseJSON(config)
case parser.FileExtensionJsonMaps:
pFn = parseJSONMaps(config)
case parser.FileExtensionJPG, parser.FileExtensionJPEG, parser.FileExtensionPNG:
pFn = parseImage(config, m.model)
default:
return nil, fmt.Errorf("[Parse] document type not support, type=%s", config.FileExtension)
}
return &p{parseFn: pFn}, nil
}
解析器选择逻辑:
- PDF文档:调用Python脚本解析,支持OCR文字识别
- Word文档:调用Python脚本解析,保持格式结构
- 文本文件:直接文本处理,支持多种编码
- Markdown:解析Markdown语法,保持层级结构
- CSV/Excel:表格数据解析,保持行列关系
- 图片文件:OCR识别 + 大模型描述生成
- JSON文件:结构化数据解析
5.2 各文档解析器实现
5.2.1 文本文档解析器
// backend/infra/impl/document/parser/builtin/parse_text.go
func parseText(config *contract.Config) parseFn {
return func(ctx context.Context, reader io.Reader, opts ...parser.Option) (docs []*schema.Document, err error) {
// 1. 读取文本内容
b, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
// 2. 根据分块策略选择处理方式
cs := config.ChunkingStrategy
if cs.ChunkType == contract.ChunkTypeCustom {
// 自定义分块:按分隔符分割
return chunkCustom(b, cs, opts...)
} else {
// 默认分块:按长度分割
return chunkDefault(b, cs, opts...)
}
}
}
文本处理流程:
- 读取内容:读取文本文件内容
- 策略选择:根据配置选择分块策略
- 智能分块:按语义边界进行分块
- 文本清理:移除多余空格、URL等
处理后的用途: 文本内容会:
- 转换为向量存储在向量数据库
- 建立全文索引支持关键词搜索
- 支持语义检索和相似度匹配
5.2.2 图片文档解析器
// backend/infra/impl/document/parser/builtin/parse_image.go
func parseImage(config *contract.Config, model chatmodel.BaseChatModel) parseFn {
return func(ctx context.Context, reader io.Reader, opts ...parser.Option) (docs []*schema.Document, err error) {
options := parser.GetCommonOptions(&parser.Options{}, opts...)
doc := &schema.Document{
MetaData: map[string]any{},
}
for k, v := range options.ExtraMeta {
doc.MetaData[k] = v
}
switch config.ParsingStrategy.ImageAnnotationType {
case contract.ImageAnnotationTypeModel:
if model == nil {
return nil, errorx.New(errno.ErrKnowledgeNonRetryableCode, errorx.KV("reason", "model is not provided"))
}
bytes, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
b64 := base64.StdEncoding.EncodeToString(bytes)
mime := fmt.Sprintf("image/%s", config.FileExtension)
url := fmt.Sprintf("data:%s;base64,%s", mime, b64)
input := &schema.Message{
Role: schema.User,
MultiContent: []schema.ChatMessagePart{
{
Type: schema.ChatMessagePartTypeText,
Text: "简短描述下这张图片",
},
{
Type: schema.ChatMessagePartTypeImageURL,
ImageURL: &schema.ChatMessageImageURL{
URL: url,
MIMEType: mime,
},
},
},
}
output, err := model.Generate(ctx, []*schema.Message{input})
if err != nil {
return nil, fmt.Errorf("[parseImage] model generate failed: %w", err)
}
doc.Content = output.Content
case contract.ImageAnnotationTypeManual:
// do nothing
default:
return nil, fmt.Errorf("[parseImage] unknown image annotation type=%d", config.ParsingStrategy.ImageAnnotationType)
}
return []*schema.Document{doc}, nil
}
}
图片处理流程:
- 读取图片:读取图片文件内容
- 模式判断:根据
ImageAnnotationType
判断处理模式 - 自动标注模式:
- 将图片转换为 base64 格式
- 构建多模态输入(文本提示 + 图片URL)
- 调用大模型生成图片描述
- 手动标注模式:直接使用用户提供的描述
- 元数据添加:添加图片相关的元数据
处理后的用途: 图片内容会:
- 转换为文本描述存储在向量数据库
- 支持基于描述的图片检索
- 建立图片内容的语义索引
- 支持图片内容的语义搜索
图片文档的完整处理流程:
- 文档上传:用户上传图片文件(JPG、JPEG、PNG)
- 类型识别:系统识别为图片类型文档
- 配置检查:检查是否支持自动标注功能
- 异步处理:通过消息队列触发异步处理
- 图片解析:
- 读取图片文件内容
- 根据
ImageAnnotationType
选择处理模式 - 自动模式:调用大模型生成描述
- 手动模式:使用用户提供的描述
- 向量化存储:将生成的文本描述存储到向量数据库
- 索引建立:建立语义索引支持图片内容检索
关键点:
- 多模态交互:图片 + 文本提示 → 大模型 → 文本描述
- 异步处理:图片处理不阻塞用户操作
- 错误容错:模型不可用时提供明确的错误信息
- 格式兼容:支持多种常见图片格式
5.2.3 表格文档解析器
// backend/infra/impl/document/parser/builtin/parse_csv.go
func parseCSV(config *contract.Config) parseFn {
return func(ctx context.Context, reader io.Reader, opts ...parser.Option) (docs []*schema.Document, err error) {
// 1. 创建CSV读取器
iter := &csvIterator{csv.NewReader(utfbom.SkipOnly(reader))}
// 2. 按行处理表格数据
return parseByRowIterator(iter, config, opts...)
}
}
type csvIterator struct {
reader *csv.Reader
}
func (c *csvIterator) NextRow() (row []string, end bool, err error) {
row, e := c.reader.Read()
if e != nil {
if errors.Is(e, io.EOF) {
return nil, true, nil // 文件结束
}
return nil, false, err
}
return row, false, nil
}
表格处理流程:
- 读取表格:逐行读取CSV文件内容
- 结构保持:保持表格的行列关系
- 类型识别:自动识别每列的数据类型
- 批量处理:支持大量数据的批量导入
处理后的用途: 表格数据会:
- 直接插入到数据库表中,保持结构化关系
- 支持SQL查询和数据分析
- 建立索引提高查询性能
- 支持复杂的表格操作(如筛选、排序、聚合)
5.2.4 Excel文档解析器
// backend/infra/impl/document/parser/builtin/parse_xlsx.go
func parseXLSX(config *contract.Config) parseFn {
return func(ctx context.Context, reader io.Reader, opts ...parser.Option) (docs []*schema.Document, err error) {
// 1. 打开Excel文件
f, err := excelize.OpenReader(reader)
if err != nil {
return nil, err
}
// 2. 获取工作表ID
sheetID := 0
if config.ParsingStrategy.SheetID != nil {
sheetID = *config.ParsingStrategy.SheetID
}
// 3. 读取指定工作表
rows, err := f.Rows(f.GetSheetName(sheetID))
if err != nil {
return nil, err
}
// 4. 创建迭代器并处理
iter := &xlsxIterator{rows, 0}
return parseByRowIterator(iter, config, opts...)
}
}
type xlsxIterator struct {
rows *excelize.Rows
firstRowSize int
}
func (x *xlsxIterator) NextRow() (row []string, end bool, err error) {
end = !x.rows.Next()
if end {
return nil, end, nil
}
row, err = x.rows.Columns()
if err != nil {
return nil, false, err
}
// 5. 统一行长度,确保数据结构一致
if x.firstRowSize == 0 {
x.firstRowSize = len(row)
} else if x.firstRowSize > len(row) {
row = append(row, make([]string, x.firstRowSize-len(row))...)
} else if x.firstRowSize < len(row) {
row = row[:x.firstRowSize]
}
return row, false, nil
}
Excel处理流程:
- 打开文件:使用excelize库打开Excel文件
- 选择工作表:根据配置选择指定的工作表
- 逐行读取:按行读取Excel数据
- 长度统一:确保每行的列数一致
- 结构化处理:保持表格的行列关系
处理后的用途: Excel数据会:
- 转换为结构化数据存储在数据库中
- 支持复杂的表格操作和数据分析
- 保持原始格式和数据类型
5.2.5 Markdown文档解析器
// backend/infra/impl/document/parser/builtin/parse_markdown.go
func parseMarkdown(config *contract.Config, storage storage.Storage, ocr ocr.OCR) parseFn {
return func(ctx context.Context, reader io.Reader, opts ...parser.Option) (docs []*schema.Document, err error) {
// 1. 读取Markdown内容
b, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
// 2. 使用goldmark解析Markdown
mdParser := goldmark.DefaultParser()
node := mdParser.Parse(text.NewReader(b))
cs := config.ChunkingStrategy
ps := config.ParsingStrategy
// 3. 验证分块策略
if cs.ChunkType != contract.ChunkTypeCustom && cs.ChunkType != contract.ChunkTypeDefault {
return nil, fmt.Errorf("[parseMarkdown] chunk type not support, chunk type=%d", cs.ChunkType)
}
var (
last *schema.Document
emptySlice bool
)
// 4. 添加分块内容
addSliceContent := func(content string) {
emptySlice = false
last.Content += content
}
// 5. 创建新分块
newSlice := func(needOverlap bool) {
last = &schema.Document{
MetaData: map[string]any{},
}
// 添加元数据
for k, v := range options.ExtraMeta {
last.MetaData[k] = v
}
// 处理重叠内容
if needOverlap && cs.Overlap > 0 && len(docs) > 0 {
overlap := getOverlap([]rune(docs[len(docs)-1].Content), cs.Overlap, cs.ChunkSize)
addSliceContent(string(overlap))
}
emptySlice = true
}
// 6. 推送分块
pushSlice := func() {
if !emptySlice && last.Content != "" {
docs = append(docs, last)
newSlice(true)
}
}
// 7. 文本清理
trim := func(text string) string {
if cs.TrimURLAndEmail {
text = urlRegex.ReplaceAllString(text, "")
text = emailRegex.ReplaceAllString(text, "")
}
if cs.TrimSpace {
text = strings.TrimSpace(text)
text = spaceRegex.ReplaceAllString(text, " ")
}
return text
}
// 8. 遍历Markdown节点并处理
// ... 遍历AST节点,提取文本内容
return docs, nil
}
}
Markdown处理流程:
- 读取内容:读取Markdown文件内容
- AST解析:使用goldmark解析Markdown语法树
- 节点遍历:遍历AST节点,提取文本内容
- 智能分块:按语义边界进行分块
- 格式保持:保持Markdown的层级结构
处理后的用途: Markdown内容会:
- 保持文档的层级结构
- 转换为向量存储在向量数据库
- 支持语义检索和关键词搜索
5.2.6 JSON文档解析器
// backend/infra/impl/document/parser/builtin/parse_json.go
func parseJSON(config *contract.Config) parseFn {
return func(ctx context.Context, reader io.Reader, opts ...parser.Option) (docs []*schema.Document, err error) {
// 1. 读取JSON内容
b, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
// 2. 解析JSON数组
var rawSlices []map[string]string
if err = json.Unmarshal(b, &rawSlices); err != nil {
return nil, err
}
if len(rawSlices) == 0 {
return nil, fmt.Errorf("[parseJSON] json data is empty")
}
// 3. 构建表头
var header []string
if config.ParsingStrategy.IsAppend {
for _, col := range config.ParsingStrategy.Columns {
header = append(header, col.Name)
}
} else {
for k := range rawSlices[0] {
header = append(header, k)
}
}
// 4. 创建JSON迭代器
iter := &jsonIterator{
header: header,
rows: rawSlices,
i: 0,
}
// 5. 按行处理JSON数据
return parseByRowIterator(iter, config, opts...)
}
}
type jsonIterator struct {
header []string
rows []map[string]string
i int
}
func (j *jsonIterator) NextRow() (row []string, end bool, err error) {
if j.i >= len(j.rows) {
return nil, true, nil
}
currentRow := j.rows[j.i]
row = make([]string, len(j.header))
for i, key := range j.header {
row[i] = currentRow[key]
}
j.i++
return row, false, nil
}
JSON处理流程:
- 读取JSON:读取JSON文件内容
- 解析数组:解析JSON数组为map切片
- 构建表头:从JSON对象中提取键作为表头
- 逐行处理:将每个JSON对象转换为表格行
- 结构化存储:保持JSON的结构化特性
处理后的用途: JSON数据会:
- 转换为表格形式存储在数据库中
- 保持JSON的键值对关系
- 支持结构化查询和分析
5.2.7 JSONMaps文档解析器
// backend/infra/impl/document/parser/builtin/parse_json_maps.go
func parseJSONMaps(config *contract.Config) parseFn {
return func(ctx context.Context, reader io.Reader, opts ...parser.Option) (docs []*schema.Document, err error) {
// 1. 读取JSON内容
b, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
// 2. 解析为自定义内容格式
var customContent []map[string]string
if err = json.Unmarshal(b, &customContent); err != nil {
return nil, err
}
// 3. 设置默认解析策略
if config.ParsingStrategy == nil {
config.ParsingStrategy = &contract.ParsingStrategy{
HeaderLine: 0,
DataStartLine: 1,
RowsCount: 0,
}
}
// 4. 创建自定义内容容器
iter := &customContentContainer{
i: 0,
colIdx: nil,
customContent: customContent,
curColumns: config.ParsingStrategy.Columns,
}
// 5. 创建新配置
newConfig := &contract.Config{
FileExtension: config.FileExtension,
ParsingStrategy: &contract.ParsingStrategy{
SheetID: config.ParsingStrategy.SheetID,
HeaderLine: 0,
DataStartLine: 1,
RowsCount: 0,
IsAppend: config.ParsingStrategy.IsAppend,
Columns: config.ParsingStrategy.Columns,
},
ChunkingStrategy: config.ChunkingStrategy,
}
// 6. 按行处理自定义内容
return parseByRowIterator(iter, newConfig, opts...)
}
}
type customContentContainer struct {
i int
colIdx map[string]int
customContent []map[string]string
curColumns []*document.Column
}
JSONMaps处理流程:
- 读取JSON:读取JSON文件内容
- 解析格式:解析为自定义的map格式
- 策略配置:设置默认的解析策略
- 容器创建:创建自定义内容容器
- 逐行处理:按行处理自定义内容
处理后的用途: JSONMaps数据会:
- 支持自定义的JSON格式
- 转换为结构化数据存储
- 保持JSON的灵活性
5.3 智能分块算法实现
核心思想: 不是简单按字数切分,而是按语义边界(如句号、段落)进行智能分块
// backend/infra/impl/document/parser/builtin/chunk.go
func chunkCustom(content []byte, cs *contract.ChunkingStrategy, opts ...parser.Option) (docs []*schema.Document, err error) {
// 1. 文本预处理
text := string(content)
text = preprocessText(text, cs)
// 2. 按分隔符分割
chunks := splitBySeparators(text, cs.Separators)
// 3. 长度控制和重叠处理
for _, chunk := range chunks {
if len(chunk) > cs.ChunkSize {
// 超长分块需要进一步分割
subChunks := splitLongChunk(chunk, cs.ChunkSize, cs.Overlap)
for _, subChunk := range subChunks {
docs = append(docs, createDocument(subChunk, opts...))
}
} else {
docs = append(docs, createDocument(chunk, opts...))
}
}
return docs, nil
}
func chunkDefault(content []byte, cs *contract.ChunkingStrategy, opts ...parser.Option) (docs []*schema.Document, err error) {
// 1. 文本预处理
text := string(content)
text = preprocessText(text, cs)
// 2. 按长度分割
runes := []rune(text)
for i := 0; i < len(runes); i += cs.ChunkSize - cs.Overlap {
end := i + cs.ChunkSize
if end > len(runes) {
end = len(runes)
}
chunk := string(runes[i:end])
docs = append(docs, createDocument(chunk, opts...))
}
return docs, nil
}
智能分块算法详解:
- 语义分割:优先按句号、段落等语义边界分割
- 长度控制:确保每个分块不超过指定长度
- 重叠处理:相邻分块之间保持一定重叠,避免语义断裂
- 文本清理:移除URL、邮箱、多余空格等干扰信息
算法特点:
- 语义保持:尽量在语义边界处分割,保持内容完整性
- 长度均衡:控制分块大小,便于后续处理
- 重叠机制:通过重叠避免重要信息丢失
- 灵活配置:支持自定义分隔符和分块策略
5.4 解析器配置说明
5.4.1 分块策略配置
用于: 所有文本类解析器(文本、Markdown、JSON等)
// backend/infra/contract/document/parser/contract.go
type ChunkingStrategy struct {
ChunkType ChunkType `json:"chunk_type"` // 分块类型:默认/自定义/层级
ChunkSize int `json:"chunk_size"` // 分块大小(字符数)
Overlap int `json:"overlap"` // 重叠大小(字符数)
Separators []string `json:"separators"` // 自定义分隔符
TrimSpace bool `json:"trim_space"` // 是否清理空格
TrimURLAndEmail bool `json:"trim_url_and_email"` // 是否移除URL和邮箱
}
配置说明:
- ChunkType:选择分块策略(默认按长度、自定义按分隔符、层级按结构)
- ChunkSize:设置每个分块的最大字符数
- Overlap:设置相邻分块的重叠字符数
- Separators:自定义分隔符列表(用于自定义分块)
- TrimSpace:是否清理多余空格
- TrimURLAndEmail:是否移除URL和邮箱地址
5.4.2 解析策略配置
用于: 表格类解析器(CSV、Excel、JSON等)
type ParsingStrategy struct {
HeaderLine int `json:"header_line"` // 表头行号
DataStartLine int `json:"data_start_line"` // 数据开始行号
RowsCount int `json:"rows_count"` // 处理行数限制
SheetID *int `json:"sheet_id"` // Excel工作表ID
IsAppend bool `json:"is_append"` // 是否为追加模式
Columns []*Column `json:"columns"` // 列配置
}
配置说明:
- HeaderLine:指定表头所在行号
- DataStartLine:指定数据开始的行号
- RowsCount:限制处理的行数(0表示全部)
- SheetID:Excel文件中要处理的工作表ID
- IsAppend:是否为追加模式(追加到现有数据)
- Columns:列的具体配置(名称、类型等)
5.4.3 图片解析配置
用于: 图片解析器
type ParsingStrategy struct {
ImageAnnotationType ImageAnnotationType `json:"image_annotation_type"` // 图片内容标注类型
}
type ImageAnnotationType int64
const (
ImageAnnotationTypeModel ImageAnnotationType = 0 // 模型自动标注
ImageAnnotationTypeManual ImageAnnotationType = 1 // 人工标注
)
配置说明:
- ImageAnnotationType:选择图片标注类型
ImageAnnotationTypeModel
(0):使用大模型自动生成图片描述ImageAnnotationTypeManual
(1):使用人工标注的描述
图片处理特点:
- 多模态输入:将图片转换为 base64 格式,与大模型进行多模态交互
- 自动描述生成:使用中文提示"简短描述下这张图片"让模型生成描述
- 格式支持:支持 JPG、JPEG、PNG 等常见图片格式
- 错误处理:当模型不可用时,会返回相应的错误信息
5.5 文档中嵌入图片的处理机制
在Coze Studio的RAG系统中,除了支持单独的图片文件外,还支持处理文档中嵌入的图片。这个功能通过ParsingStrategy
中的两个关键配置实现,能够从Markdown、PDF、Word等文档中提取图片,并进行OCR文字识别,为RAG系统提供更丰富的多模态知识源。
5.5.1 嵌入图片处理配置
// backend/infra/contract/document/parser/manager.go
type ParsingStrategy struct {
// Doc
ExtractImage bool `json:"extract_image"` // 提取图片元素
ExtractTable bool `json:"extract_table"` // 提取表格元素
ImageOCR bool `json:"image_ocr"` // 图片 ocr
FilterPages []int `json:"filter_pages"` // 页过滤, 第一页=1
// Sheet
SheetID *int `json:"sheet_id"` // xlsx sheet id
HeaderLine int `json:"header_line"` // 表头行
DataStartLine int `json:"data_start_line"` // 数据起始行
RowsCount int `json:"rows_count"` // 读取数据行数
IsAppend bool `json:"-"` // 行插入
Columns []*document.Column `json:"-"` // sheet 对齐表头
IgnoreColumnTypeErr bool `json:"-"` // true 时忽略 column type 与 value 未对齐的问题,此时 value 为空
// Image
ImageAnnotationType ImageAnnotationType `json:"image_annotation_type"` // 图片内容标注类型
}
type ImageAnnotationType int64
const (
ImageAnnotationTypeModel ImageAnnotationType = 0 // 模型自动标注
ImageAnnotationTypeManual ImageAnnotationType = 1 // 人工标注
)
配置组合使用:
ExtractImage=true, ImageOCR=false
: 只提取图片,不进行OCR识别ExtractImage=true, ImageOCR=true
: 提取图片并进行OCR识别ExtractImage=false, ImageOCR=true
: 无效配置,OCR需要先提取图片ExtractImage=false, ImageOCR=false
: 不处理图片(默认行为)
配置驱动的处理机制
配置驱动的处理机制是Coze Studio图片处理系统的核心设计理念,它通过灵活的配置参数来控制图片处理的行为,确保系统能够适应不同的业务需求。
配置逻辑说明:
-
ExtractImage配置:控制是否提取文档中的图片元素
- 当设置为true时,系统会扫描文档并提取所有图片
- 当设置为false时,跳过图片处理,提高处理速度
-
ImageOCR配置:控制是否对提取的图片进行OCR识别
- 需要先启用ExtractImage才能使用OCR功能
- OCR识别会增加处理时间,但能提取图片中的文字内容
-
FilterPages配置:支持页面级别的过滤
- 可以指定只处理特定页面,跳过不需要的页面
- 适用于大型文档的增量处理
-
ImageAnnotationType配置:控制图片标注的方式
- 模型自动标注:使用AI模型自动生成图片描述
- 人工标注:支持人工为图片添加描述信息
处理流程总结:
整个嵌入图片处理流程可以总结为:文档解析 → 图片检测 → 图片提取 → 图片存储 → OCR识别(可选) → 内容整合。这个机制确保了文档中的图片内容能够被有效利用,为RAG系统提供更丰富的知识源,支持图片内容的语义搜索和问答。
5.5.2 嵌入图片处理
嵌入图片的处理是RAG系统中一个重要的多模态处理环节,它能够从文档中提取图片内容,并通过OCR技术将图片转换为可检索的文本信息。整个处理流程包括图片检测、提取、存储、OCR识别和内容整合等多个阶段。
处理逻辑概述:
- 图片检测阶段:系统首先扫描文档内容,识别其中的图片元素
- 图片提取阶段:根据文档类型采用不同的提取策略,获取图片数据
- 图片存储阶段:将提取的图片保存到存储系统,生成访问URL
- OCR识别阶段:对图片进行文字识别,提取文本内容
- 内容整合阶段:将图片URL和OCR文本整合到文档分块中
1. 图片提取流程
图片提取是整个处理流程的第一步,需要根据不同的文档类型采用相应的提取策略。系统支持从Markdown、PDF、Word等多种文档格式中提取图片。
Markdown文档中的图片处理:
Markdown文档中的图片处理采用AST(抽象语法树)遍历的方式,通过解析Markdown语法树来识别和处理图片节点。
处理逻辑:
- 图片节点检测:遍历AST树,识别
ast.KindImage
类型的节点 - 配置验证:检查
ps.ExtractImage
配置是否启用图片提取 - URL验证:验证图片URL的有效性,确保是可访问的网络地址
- 文件扩展名提取:从URL中提取文件扩展名,用于后续存储
- 图片下载:通过网络下载图片数据
- 图片存储:将图片保存到存储系统,生成访问URL
- 内容整合:将图片URL添加到文档分块中
- OCR处理:如果启用OCR,对图片进行文字识别并添加识别结果
- 分块控制:根据分块大小控制内容分割
// backend/infra/impl/document/parser/builtin/parse_markdown.go
case ast.KindImage:
if !ps.ExtractImage {
break
}
imageNode := n.(*ast.Image)
if ps.ExtractImage {
imageURL := string(imageNode.Destination)
if _, err = url.ParseRequestURI(imageURL); err == nil {
sp := strings.Split(imageURL, ".")
if len(sp) == 0 {
return ast.WalkStop, fmt.Errorf("failed to extract image extension, url=%s", imageURL)
}
ext := sp[len(sp)-1]
img, err := downloadImage(ctx, imageURL)
if err != nil {
return ast.WalkStop, fmt.Errorf("failed to download image: %w", err)
}
imgSrc, err := putImageObject(ctx, storage, ext, getCreatorIDFromExtraMeta(options.ExtraMeta), img)
if err != nil {
return ast.WalkStop, err
}
if !emptySlice && last.Content != "" {
pushSlice()
} else {
newSlice(false)
}
addSliceContent(fmt.Sprintf("\n%s\n", imgSrc))
if ps.ImageOCR && ocr != nil {
texts, err := ocr.FromBase64(ctx, base64.StdEncoding.EncodeToString(img))
if err != nil {
return ast.WalkStop, fmt.Errorf("failed to perform OCR on image: %w", err)
}
addSliceContent(strings.Join(texts, "\n"))
}
if charCount(last.Content) >= cs.ChunkSize {
pushSlice()
}
} else {
logs.CtxInfof(ctx, "[parseMarkdown] not a valid image url, skip, got=%s", imageURL)
}
}
PDF文档中的图片处理:
PDF文档中的图片处理使用pdfplumber库,通过解析PDF的内部结构来提取嵌入的图片。
处理逻辑:
- 页面遍历:逐页处理PDF文档,支持页面过滤
- 文本提取:首先提取页面中的文本内容
- 图片检测:检查页面是否包含图片元素
- 图片数据提取:从PDF流中提取图片的原始数据
- 格式处理:根据图片的压缩格式(DCT、Flate等)进行相应的解码处理
- 颜色空间转换:处理CMYK等特殊颜色空间,转换为标准RGB格式
- 图片保存:将处理后的图片保存为PNG格式
- Base64编码:将图片数据编码为Base64字符串,便于存储和传输
- 元数据记录:记录图片在页面中的位置信息(边界框坐标)
# backend/infra/impl/document/parser/builtin/parse_pdf.py
def extract_pdf_content(pdf_data: bytes, extract_images, extract_tables: bool, filter_pages: []):
with pdfplumber.open(io.BytesIO(pdf_data)) as pdf:
content = []
for page_num, page in enumerate(pdf.pages):
if filter_pages is not None and page_num + 1 in filter_pages:
print(f"Skip page {page_num + 1}...")
continue
print(f"Processing page {page_num + 1}...")
text = page.extract_text(x_tolerance=2)
content.append({
'type': 'text',
'content': text,
'page': page_num + 1,
'bbox': page.bbox
})
if extract_images:
images = page.images
for img_index, img in enumerate(images):
try:
filters = img['stream'].get_filters()
data = img['stream'].get_data()
buffered = io.BytesIO()
if filters[-1][0] in LITERALS_DCT_DECODE:
if LITERAL_DEVICE_CMYK in img['colorspace']:
i = Image.open(io.BytesIO(data))
i = ImageChops.invert(i)
i = i.convert("RGB")
i.save(buffered, format="PNG")
else:
buffered.write(data)
elif len(filters) == 1 and filters[0][0] in LITERALS_FLATE_DECODE:
width, height = img['srcsize']
channels = len(img['stream'].get_data()) / width / height / (img['bits'] / 8)
mode: Literal["1", "L", "RGB", "CMYK"]
if img['bits'] == 1:
mode = "1"
elif img['bits'] == 8 and channels == 1:
mode = "L"
elif img['bits'] == 8 and channels == 3:
mode = "RGB"
elif img['bits'] == 8 and channels == 4:
mode = "CMYK"
i = Image.frombytes(mode, img['srcsize'], data, "raw")
i.save(buffered, format="PNG")
else:
buffered.write(data)
content.append({
'type': 'image',
'content': base64.b64encode(buffered.getvalue()).decode('utf-8'),
'page': page_num + 1,
'bbox': (img['x0'], img['top'], img['x1'], img['bottom'])
})
except Exception as err:
print(f"Skipping an unsupported image on page {page_num + 1}, error message: {err}")
Word文档中的图片处理:
Word文档中的图片处理使用python-docx库,通过解析DOCX文件的XML结构来提取嵌入的图片。
处理逻辑:
- 文档解析:使用Document类解析DOCX文件结构
- 元素遍历:遍历文档中的所有元素(段落、表格、图片等)
- 图片检测:识别文档中的图片元素(通常以列表形式存储)
- 图片数据提取:从图片元素的blob属性中提取原始图片数据
- 格式转换:将图片转换为PNG格式,确保兼容性
- 内容分段:在遇到图片时,将之前的文本内容保存为独立块
- Base64编码:将图片数据编码为Base64字符串
- 类型标记:为每个内容块标记类型(text/image/table)
- 错误处理:对不支持的图片格式进行异常处理
# backend/infra/impl/document/parser/builtin/parse_docx.py
class DocxLoader(ABC):
def __init__(
self,
file_content: IO[bytes],
extract_images: bool = True,
extract_tables: bool = True,
):
self.file_content = file_content
self.extract_images = extract_images
self.extract_tables = extract_tables
def load(self) -> List[dict]:
result = []
doc = Document(self.file_content)
it = iter(doc.element.body)
text = ""
for part in it:
blocks = self.parse_part(part, doc)
if blocks is None or len(blocks) == 0:
continue
for block in blocks:
if self.extract_images and isinstance(block, list):
for b in block:
image = io.BytesIO()
try:
Image.open(io.BytesIO(b.image.blob)).save(image, format="png")
except Exception as e:
logging.error(f"load image failed, time={time.asctime()}, err:{e}")
raise RuntimeError("ExtractImageError")
if len(text) > 0:
result.append(
{
"content": text,
"type": "text",
}
)
text = ""
result.append(
{
"content": base64.b64encode(image.getvalue()).decode('utf-8'),
"type": "image",
}
)
if isinstance(block, Paragraph):
text += block.text
if self.extract_tables and isinstance(block, Table):
rows = block.rows
if len(text) > 0:
result.append(
{
"content": text,
"type": "text",
}
)
text = ""
table = self.convert_table(rows)
result.append(
{
"table": table,
"type": "table",
}
)
if text:
text += "\n\n"
if len(text) > 0:
result.append(
{
"content": text,
"type": "text",
}
)
return result
5.6 表格与数据库结合使用机制
Coze Studio不仅支持将表格文档作为知识库内容,还提供了将表格数据导入到数据库的功能。这个机制包括表格解析、列类型预测、数据库表结构验证和批量数据导入等核心功能。
5.6.1 表格解析与列类型预测
1. 表格解析器架构
// backend/domain/memory/database/internal/sheet/sheet.go
type TosTableParser struct {
UserID int64
DocumentSource database.DocumentSourceType
TosURI string
TosServ storage.Storage
}
func (t *TosTableParser) GetTableDataBySheetIDx(ctx context.Context, rMeta entity.TableReaderMeta) (*entity.TableReaderSheetData, *entity.ExcelExtraInfo, error) {
// 1. 获取文件内容
extension, fileContent, err := t.GetTosTableFile(ctx)
if err != nil {
return nil, nil, err
}
// 2. 解析本地表格元数据
localMeta, err := t.getLocalSheetMeta(ctx, rMeta.TosMaxLine)
if err != nil {
return nil, nil, err
}
// 3. 获取指定工作表数据
sheetData, err := t.getTableDataBySheetIdx(ctx, rMeta, localMeta, rMeta.SheetId)
if err != nil {
return nil, nil, err
}
return sheetData, &entity.ExcelExtraInfo{
Sheets: localMeta.SheetsNameList,
SheetsRowCount: localMeta.SheetsRowCount,
ExtensionName: localMeta.ExtensionName,
FileSize: localMeta.FileSize,
}, nil
}
2. 表格数据解析流程
表格解析的核心流程包括以下几个步骤:
- 文件获取:从存储系统获取Excel或CSV文件
- 元数据解析:解析文件的基本信息(工作表数量、行数等)
- 工作表选择:根据配置选择特定的工作表
- 数据提取:提取表头和数据行
- 类型预测:基于样本数据预测列类型
// backend/domain/memory/database/internal/sheet/sheet.go
func (t *TosTableParser) getTableDataBySheetIdx(ctx context.Context, rMeta entity.TableReaderMeta, localMeta *entity.LocalTableMeta, sheetIdx int64) (*entity.TableReaderSheetData, error) {
// 验证工作表索引
if int(sheetIdx) >= len(localMeta.SheetsRowCount) {
return nil, fmt.Errorf("start sheet index out of range")
}
res := &entity.TableReaderSheetData{
Columns: make([]*common.DocTableColumn, 0),
}
// 获取工作表总行数
sheetRowTotal := localMeta.SheetsRowCount[sheetIdx]
// 解析表头信息和样本数据
headLine, sampleData, err := t.parseSchemaInfo(ctx, localMeta, rMeta, int(sheetIdx), sheetRowTotal)
if err != nil {
return nil, err
}
if len(headLine) == 0 {
return res, nil
}
// 构建列信息
for colIndex, cell := range headLine {
res.Columns = append(res.Columns, &common.DocTableColumn{
ColumnName: cell,
Sequence: int64(colIndex),
})
}
res.SampleData = sampleData
return res, nil
}
3. 列类型自动预测
系统会根据样本数据自动预测每列的数据类型,预测算法如下:
// backend/domain/memory/database/internal/sheet/sheet.go
func (t *TosTableParser) PredictColumnType(columns []*common.DocTableColumn, sampleData [][]string, sheetIdx, startLineIdx int64) ([]*common.DocTableColumn, error) {
if len(sampleData) == 0 {
// 兜底处理:空数据时默认为文本类型
for _, column := range columns {
column.ColumnType = common.ColumnTypePtr(common.ColumnType_Text)
column.ContainsEmptyValue = ptr.Of(true)
}
return columns, nil
}
// 解析列信息
columnInfos, err := ParseDocTableColumnInfo(sampleData, len(columns))
if err != nil {
return nil, err
}
// 验证列数匹配
if len(columns) != len(columnInfos) {
return nil, errorx.New(errno.ErrMemoryDatabaseColumnNotMatch,
errorx.KVf("msg", "columnType length is not match with column count, column length:%d, predict column length:%d",
len(columns), len(columnInfos)))
}
// 设置列类型和空值信息
for i := range columns {
columns[i].ContainsEmptyValue = &columnInfos[i].ContainsEmptyValue
columns[i].ColumnType = &columnInfos[i].ColumnType
}
return columns, nil
}
4. 单元格类型识别算法
系统使用优先级链来识别单元格类型,优先级从高到低为:数字 > 浮点数 > 布尔值 > 日期 > 文本
// backend/domain/memory/database/internal/sheet/sheet.go
func predictColumnType(columnContent []string) (common.ColumnType, bool) {
columnType := common.ColumnType_Text
containsEmptyValue := false
for i, col := range columnContent {
if col == "" {
containsEmptyValue = true
continue
}
cellType := GetCellType(col)
if i == 0 {
columnType = cellType
continue
}
// 类型冲突时降级为文本类型
if GetColumnTypeCategory(columnType) != GetColumnTypeCategory(cellType) {
return common.ColumnType_Text, containsEmptyValue
}
// 选择优先级更高的类型
if GetColumnTypePriority(cellType) < GetColumnTypePriority(columnType) {
columnType = cellType
}
}
return columnType, containsEmptyValue
}
// 数字类型识别
func IdentifyNumber(cellValue string) *common.ColumnType {
_, err := strconv.ParseInt(cellValue, 10, 64)
if err != nil {
return nil
}
return common.ColumnTypePtr(common.ColumnType_Number)
}
// 浮点数类型识别
func IdentifyFloat(cellValue string) *common.ColumnType {
_, err := strconv.ParseFloat(cellValue, 64)
if err != nil {
return nil
}
return common.ColumnTypePtr(common.ColumnType_Float)
}
// 布尔类型识别
func IdentifyBoolean(cellValue string) *common.ColumnType {
lowerCellValue := strings.ToLower(cellValue)
if lowerCellValue != "true" && lowerCellValue != "false" {
return nil
}
return common.ColumnTypePtr(common.ColumnType_Boolean)
}
// 日期类型识别
func IdentifyDate(cellValue string) *common.ColumnType {
matched, err := regexp.MatchString(dateTimePattern, cellValue)
if err != nil || !matched {
return nil
}
return common.ColumnTypePtr(common.ColumnType_Date)
}
5.6.2 数据库表结构验证
1. 表结构验证流程
在将表格数据导入数据库之前,系统会进行严格的结构验证:
// backend/domain/memory/database/service/database_impl.go
func (d databaseService) ValidateDatabaseTableSchema(ctx context.Context, req *ValidateDatabaseTableSchemaRequest) (*ValidateDatabaseTableSchemaResponse, error) {
parser := &sheet.TosTableParser{
UserID: req.UserID,
DocumentSource: database.DocumentSourceType_Document,
TosURI: req.TosURL,
TosServ: d.storage,
}
// 解析表格数据
res, sheetRes, err := parser.GetTableDataBySheetIDx(ctx, entity2.TableReaderMeta{
TosMaxLine: 100000,
HeaderLineIdx: req.TableSheet.HeaderLineIdx,
SheetId: req.TableSheet.SheetID,
StartLineIdx: req.TableSheet.StartLineIdx,
ReaderMethod: database.TableReadDataMethodAll,
ReadLineCnt: 20,
})
if err != nil {
return nil, err
}
// 验证表结构
valid, invalidMsg := sheet.CheckSheetIsValid(req.Fields, res.Columns, sheetRes)
return &ValidateDatabaseTableSchemaResponse{
Valid: valid,
InvalidMsg: invalidMsg,
}, nil
}
2. 验证规则详解
验证过程包括以下四个主要检查:
- 表头名称对齐:检查表格列名与数据库字段名是否匹配
- 必填字段验证:检查索引列(indexing字段)是否有值
- 类型转换验证:检查数据类型是否可以安全转换
- 字段完整性验证:确保所有数据库字段都在表格中存在
// backend/domain/memory/database/internal/sheet/sheet.go
func CheckSheetIsValid(fields []*database.FieldItem, parsedColumns []*common.DocTableColumn, sheet *entity.ExcelExtraInfo) (bool, *string) {
// 创建字段映射
fieldMapping := make(map[string]*database.FieldItem)
for _, field := range fields {
fieldMapping[field.Name] = field
}
// 检查每个解析出的列
for _, col := range parsedColumns {
field, found := fieldMapping[col.ColumnName]
if !found {
continue
}
delete(fieldMapping, col.ColumnName)
// 验证类型转换
if !canTransformColumnType(col.ColumnType, field.Type) {
return false, ptr.Of(fmt.Sprintf("column type invalid, expected=%d, got=%d", field.Type, col.ColumnType))
}
// 验证必填字段
if field.Indexing {
for _, vals := range sampleData {
val := vals[col.Sequence]
if val == "" {
return false, ptr.Of("column indexing requires value, but got none")
}
}
}
}
// 验证所有字段都被包含
if len(fieldMapping) != 0 {
return false, ptr.Of("some fields are missing in the sheet")
}
return true, nil
}
5.6.3 数据转换与导入
1. 数据类型转换
系统支持多种数据类型的转换,确保数据能够正确存储到数据库中:
// backend/domain/knowledge/internal/convert/table.go
func ParseAnyData(col *entity.TableColumn, data any) (*document.ColumnData, error) {
resp := &document.ColumnData{
ColumnID: col.ID,
ColumnName: col.Name,
Type: col.Type,
}
if data == nil {
return resp, nil
}
switch col.Type {
case document.TableColumnTypeString:
switch v := data.(type) {
case string:
resp.ValString = ptr.Of(v)
case []byte:
resp.ValString = ptr.Of(string(v))
default:
return nil, fmt.Errorf("[ParseAnyData] type assertion failed")
}
case document.TableColumnTypeInteger:
switch data.(type) {
case int, int8, int16, int32, int64:
resp.ValInteger = ptr.Of(reflect.ValueOf(data).Int())
case uint, uint8, uint16, uint32, uint64, uintptr:
resp.ValInteger = ptr.Of(int64(reflect.ValueOf(data).Uint()))
default:
return nil, fmt.Errorf("[ParseAnyData] type assertion failed")
}
case document.TableColumnTypeTime:
if t, ok := data.(time.Time); ok {
resp.ValTime = &t
} else if b, ok := data.([]byte); ok {
t, err := time.Parse(timeFormat, string(b))
if err != nil {
return nil, fmt.Errorf("[ParseAnyData] format time failed, %w", err)
}
resp.ValTime = &t
} else {
return nil, fmt.Errorf("[ParseAnyData] type assertion failed")
}
case document.TableColumnTypeNumber:
switch data.(type) {
case float32, float64:
resp.ValNumber = ptr.Of(reflect.ValueOf(data).Float())
default:
return nil, fmt.Errorf("[ParseAnyData] type assertion failed")
}
case document.TableColumnTypeBoolean:
switch data.(type) {
case bool:
resp.ValBoolean = ptr.Of(data.(bool))
default:
return nil, fmt.Errorf("[ParseAnyData] type assertion failed")
}
}
return resp, nil
}
2. 批量数据导入流程
数据导入采用异步处理方式,避免阻塞用户操作:
// backend/domain/memory/database/service/database_impl.go
func (d databaseService) SubmitDatabaseInsertTask(ctx context.Context, req *SubmitDatabaseInsertTaskRequest) error {
parser := &sheet.TosTableParser{
UserID: req.UserID,
DocumentSource: database.DocumentSourceType_Document,
TosURI: req.FileURI,
TosServ: d.storage,
}
// 解析表格数据
parseData, extra, err := parser.GetTableDataBySheetIDx(ctx, entity2.TableReaderMeta{
TosMaxLine: 100000,
SheetId: req.TableSheet.SheetID,
HeaderLineIdx: req.TableSheet.HeaderLineIdx,
StartLineIdx: req.TableSheet.StartLineIdx,
ReaderMethod: database.TableReadDataMethodAll,
ReadLineCnt: req.TableSheet.TotalRows,
})
if err != nil {
return err
}
// 初始化缓存,用于跟踪导入进度
err = d.initializeCache(ctx, req, parseData, extra)
if err != nil {
return err
}
// 异步执行数据导入,避免阻塞
go func() {
d.executeDatabaseInsertTask(ctx, req, parseData, extra)
}()
return nil
}
3. 导入进度跟踪
系统通过Redis缓存来跟踪导入进度,用户可以实时查看导入状态:
// backend/domain/memory/database/service/database_impl.go
func (d databaseService) increaseProgress(ctx context.Context, req *SubmitDatabaseInsertTaskRequest, successNum int64) error {
progressKey := fmt.Sprintf(progressKey, req.DatabaseID, req.UserID)
return d.cache.IncrBy(ctx, progressKey, successNum).Err()
}
整个表格与数据库结合使用的流程可以总结为:表格文件上传 → 解析表格结构 → 预测列类型 → 验证表结构 → 转换数据类型 → 异步批量导入 → 进度跟踪。这个设计确保了数据导入的准确性和系统的响应性。