【扣子源码分析】Coze Studio RAG 技术深度解析(一):文档入库处理完整实现

本文档基于字节跳动(扣子) 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 异步处理流程

核心思想: 文档入库采用异步处理模式,通过消息队列解耦,提高系统性能和可靠性

完整的异步调用链路:

  1. 文档创建CreateDocument() 方法
  2. 处理器处理DocProcessor.Indexing() 方法
  3. 发送事件 → 发送 EventTypeIndexDocuments 事件到消息队列
  4. 消息消费HandleMessage() 接收消息
  5. 事件分发 → 根据事件类型调用对应处理方法
  6. 文档处理indexDocument() 处理单个文档
  7. 分块存储 → 解析文档并存储到向量数据库

异步处理优势:

  • 性能提升:文档上传不阻塞用户操作
  • 可靠性:消息队列提供重试机制
  • 解耦设计:文档创建和索引处理分离
  • 并发处理:支持多个文档同时处理
  • 错误隔离:单个文档处理失败不影响其他文档

四、源码深度分析

4.1 服务入口 - CreateDocument

CreateDocument 方法的作用:

  1. 文档接收入口:接收用户上传的文档请求
  2. 参数验证:检查请求参数的合法性
  3. 文档预处理:将文档URL转换为存储URI,下载文档内容
  4. 创建处理器:根据文档类型创建对应的处理器
  5. 同步处理:执行文档的同步处理步骤(存储、数据库操作)
  6. 触发异步任务:通过 Indexing() 方法发送消息到队列,启动异步索引

关键点:

  • 快速响应:立即返回文档信息,不等待索引完成
  • 同步+异步分离:文档元数据同步保存,内容解析异步处理
  • 错误处理:每个步骤都有详细的错误处理机制
  • 事务安全:数据库操作使用事务保证一致性

如何触发异步任务:

  1. 调用 Indexing() 方法:在 CreateDocument 的最后阶段调用
  2. 发送消息到队列Indexing() 方法会创建索引事件并发送到消息队列
  3. 异步消费处理:消息队列的消费者会接收消息并调用 indexDocument 方法
  4. 文档解析存储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
}

完整处理流程:

  1. 获取文档:从存储中获取文档内容
  2. 选择解析器:根据文档类型选择合适的解析器
  3. 解析内容:使用解析器解析文档内容
  4. 生成分块:将解析结果分割成多个分块
  5. 保存分块:将分块保存到数据库
  6. 向量化存储:将分块存储到向量数据库和ES
  7. 状态更新:更新文档处理状态

处理后的用途: 所有文档最终都会:

  • 转换为结构化的分块数据
  • 存储在向量数据库支持语义检索
  • 建立全文索引支持关键词搜索
  • 保持原始文档的结构和关系

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...)
        }
    }
}

文本处理流程:

  1. 读取内容:读取文本文件内容
  2. 策略选择:根据配置选择分块策略
  3. 智能分块:按语义边界进行分块
  4. 文本清理:移除多余空格、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
    }
}

图片处理流程:

  1. 读取图片:读取图片文件内容
  2. 模式判断:根据 ImageAnnotationType 判断处理模式
  3. 自动标注模式
    • 将图片转换为 base64 格式
    • 构建多模态输入(文本提示 + 图片URL)
    • 调用大模型生成图片描述
  4. 手动标注模式:直接使用用户提供的描述
  5. 元数据添加:添加图片相关的元数据

处理后的用途: 图片内容会:

  • 转换为文本描述存储在向量数据库
  • 支持基于描述的图片检索
  • 建立图片内容的语义索引
  • 支持图片内容的语义搜索

图片文档的完整处理流程:

  1. 文档上传:用户上传图片文件(JPG、JPEG、PNG)
  2. 类型识别:系统识别为图片类型文档
  3. 配置检查:检查是否支持自动标注功能
  4. 异步处理:通过消息队列触发异步处理
  5. 图片解析
    • 读取图片文件内容
    • 根据 ImageAnnotationType 选择处理模式
    • 自动模式:调用大模型生成描述
    • 手动模式:使用用户提供的描述
  6. 向量化存储:将生成的文本描述存储到向量数据库
  7. 索引建立:建立语义索引支持图片内容检索

关键点:

  • 多模态交互:图片 + 文本提示 → 大模型 → 文本描述
  • 异步处理:图片处理不阻塞用户操作
  • 错误容错:模型不可用时提供明确的错误信息
  • 格式兼容:支持多种常见图片格式
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
}

表格处理流程:

  1. 读取表格:逐行读取CSV文件内容
  2. 结构保持:保持表格的行列关系
  3. 类型识别:自动识别每列的数据类型
  4. 批量处理:支持大量数据的批量导入

处理后的用途: 表格数据会:

  • 直接插入到数据库表中,保持结构化关系
  • 支持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处理流程:

  1. 打开文件:使用excelize库打开Excel文件
  2. 选择工作表:根据配置选择指定的工作表
  3. 逐行读取:按行读取Excel数据
  4. 长度统一:确保每行的列数一致
  5. 结构化处理:保持表格的行列关系

处理后的用途: 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处理流程:

  1. 读取内容:读取Markdown文件内容
  2. AST解析:使用goldmark解析Markdown语法树
  3. 节点遍历:遍历AST节点,提取文本内容
  4. 智能分块:按语义边界进行分块
  5. 格式保持:保持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处理流程:

  1. 读取JSON:读取JSON文件内容
  2. 解析数组:解析JSON数组为map切片
  3. 构建表头:从JSON对象中提取键作为表头
  4. 逐行处理:将每个JSON对象转换为表格行
  5. 结构化存储:保持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处理流程:

  1. 读取JSON:读取JSON文件内容
  2. 解析格式:解析为自定义的map格式
  3. 策略配置:设置默认的解析策略
  4. 容器创建:创建自定义内容容器
  5. 逐行处理:按行处理自定义内容

处理后的用途: 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
}

智能分块算法详解:

  1. 语义分割:优先按句号、段落等语义边界分割
  2. 长度控制:确保每个分块不超过指定长度
  3. 重叠处理:相邻分块之间保持一定重叠,避免语义断裂
  4. 文本清理:移除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图片处理系统的核心设计理念,它通过灵活的配置参数来控制图片处理的行为,确保系统能够适应不同的业务需求。

配置逻辑说明:

  1. ExtractImage配置:控制是否提取文档中的图片元素

    • 当设置为true时,系统会扫描文档并提取所有图片
    • 当设置为false时,跳过图片处理,提高处理速度
  2. ImageOCR配置:控制是否对提取的图片进行OCR识别

    • 需要先启用ExtractImage才能使用OCR功能
    • OCR识别会增加处理时间,但能提取图片中的文字内容
  3. FilterPages配置:支持页面级别的过滤

    • 可以指定只处理特定页面,跳过不需要的页面
    • 适用于大型文档的增量处理
  4. ImageAnnotationType配置:控制图片标注的方式

    • 模型自动标注:使用AI模型自动生成图片描述
    • 人工标注:支持人工为图片添加描述信息

处理流程总结:

整个嵌入图片处理流程可以总结为:文档解析 → 图片检测 → 图片提取 → 图片存储 → OCR识别(可选) → 内容整合。这个机制确保了文档中的图片内容能够被有效利用,为RAG系统提供更丰富的知识源,支持图片内容的语义搜索和问答。

5.5.2 嵌入图片处理

嵌入图片的处理是RAG系统中一个重要的多模态处理环节,它能够从文档中提取图片内容,并通过OCR技术将图片转换为可检索的文本信息。整个处理流程包括图片检测、提取、存储、OCR识别和内容整合等多个阶段。

处理逻辑概述:

  1. 图片检测阶段:系统首先扫描文档内容,识别其中的图片元素
  2. 图片提取阶段:根据文档类型采用不同的提取策略,获取图片数据
  3. 图片存储阶段:将提取的图片保存到存储系统,生成访问URL
  4. OCR识别阶段:对图片进行文字识别,提取文本内容
  5. 内容整合阶段:将图片URL和OCR文本整合到文档分块中

1. 图片提取流程

图片提取是整个处理流程的第一步,需要根据不同的文档类型采用相应的提取策略。系统支持从Markdown、PDF、Word等多种文档格式中提取图片。

Markdown文档中的图片处理:

Markdown文档中的图片处理采用AST(抽象语法树)遍历的方式,通过解析Markdown语法树来识别和处理图片节点。

处理逻辑:

  1. 图片节点检测:遍历AST树,识别ast.KindImage类型的节点
  2. 配置验证:检查ps.ExtractImage配置是否启用图片提取
  3. URL验证:验证图片URL的有效性,确保是可访问的网络地址
  4. 文件扩展名提取:从URL中提取文件扩展名,用于后续存储
  5. 图片下载:通过网络下载图片数据
  6. 图片存储:将图片保存到存储系统,生成访问URL
  7. 内容整合:将图片URL添加到文档分块中
  8. OCR处理:如果启用OCR,对图片进行文字识别并添加识别结果
  9. 分块控制:根据分块大小控制内容分割
// 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的内部结构来提取嵌入的图片。

处理逻辑:

  1. 页面遍历:逐页处理PDF文档,支持页面过滤
  2. 文本提取:首先提取页面中的文本内容
  3. 图片检测:检查页面是否包含图片元素
  4. 图片数据提取:从PDF流中提取图片的原始数据
  5. 格式处理:根据图片的压缩格式(DCT、Flate等)进行相应的解码处理
  6. 颜色空间转换:处理CMYK等特殊颜色空间,转换为标准RGB格式
  7. 图片保存:将处理后的图片保存为PNG格式
  8. Base64编码:将图片数据编码为Base64字符串,便于存储和传输
  9. 元数据记录:记录图片在页面中的位置信息(边界框坐标)
# 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结构来提取嵌入的图片。

处理逻辑:

  1. 文档解析:使用Document类解析DOCX文件结构
  2. 元素遍历:遍历文档中的所有元素(段落、表格、图片等)
  3. 图片检测:识别文档中的图片元素(通常以列表形式存储)
  4. 图片数据提取:从图片元素的blob属性中提取原始图片数据
  5. 格式转换:将图片转换为PNG格式,确保兼容性
  6. 内容分段:在遇到图片时,将之前的文本内容保存为独立块
  7. Base64编码:将图片数据编码为Base64字符串
  8. 类型标记:为每个内容块标记类型(text/image/table)
  9. 错误处理:对不支持的图片格式进行异常处理
# 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. 表格数据解析流程

表格解析的核心流程包括以下几个步骤:

  1. 文件获取:从存储系统获取Excel或CSV文件
  2. 元数据解析:解析文件的基本信息(工作表数量、行数等)
  3. 工作表选择:根据配置选择特定的工作表
  4. 数据提取:提取表头和数据行
  5. 类型预测:基于样本数据预测列类型
// 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. 验证规则详解

验证过程包括以下四个主要检查:

  1. 表头名称对齐:检查表格列名与数据库字段名是否匹配
  2. 必填字段验证:检查索引列(indexing字段)是否有值
  3. 类型转换验证:检查数据类型是否可以安全转换
  4. 字段完整性验证:确保所有数据库字段都在表格中存在
// 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()
}

整个表格与数据库结合使用的流程可以总结为:表格文件上传 → 解析表格结构 → 预测列类型 → 验证表结构 → 转换数据类型 → 异步批量导入 → 进度跟踪。这个设计确保了数据导入的准确性和系统的响应性。

### Coze 文档处理方法概述 Coze种专注于高效数据提取和文档解析技术工具集,广泛应用于结构化与非结构化数据的转换场景。以下是有关 Coze些核心功能及其应用方式: #### 数据提取技术 Coze 提供了种基于模式匹配的数据提取机制,能够快速定位并提取目标字段的内容。这种技术特别适用于 PDF、Word 和 Excel 文件中的复杂表格或嵌套数据[^1]。 ```python from coze import DocumentParser parser = DocumentParser() document = parser.load_document('example.pdf') extracted_data = document.extract_fields(['invoice_number', 'total_amount']) print(extracted_data) ``` 上述代码片段展示了如何加载个 PDF 文件并通过指定字段名来提取所需的信息。`DocumentParser` 类提供了灵活的方法支持多种文件类型的读取操作[^2]。 #### 自定义模板配置 为了适应不同业务需求下的多样化文档格式,Coze 支持通过 YAML 或 JSON 配置自定义模板。这些模板可以精确描述每类文档的关键特征以及对应的解析逻辑[^3]。 ```yaml template_name: invoice_template fields: - name: customer_id selector: "#customer-id" - name: items_list table_selector: ".items-table tr td:nth-child(2)" ``` 此 YAML 片段定义了个发票模板,其中包含了两个主要部分:客户 ID 字段的选择器路径及商品列表所在的 HTML 表格位置信息[^4]。 #### 错误处理与日志记录 在实际项目开发过程中,良好的错误捕获能力和详尽的日志输出对于调试至关重要。Coze 内建有完善的异常管理框架,并允许开发者轻松集成第三方监控服务[^5]。 ```python try: processed_result = document.apply_transformation(transformation_rules) except TransformationError as e: logger.error(f"Failed to apply transformation rules due to {e}") finally: audit_logger.info("Processing completed with status code %d", result_status_code) ``` 以上 Python 脚本示范了当尝试执行某些复杂的文档变换规则失败时应采取怎样的措施来进行妥善处置[^6]。 ---
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值