Cove API 的 RAG 模块拆解:一套面向 Agent 的可组合知识检索工具体系

本文对应的完整后端实现已经开源:

Cove API - Go

这是一个用 Go 写的 AI 助手平台后端,把流式对话、RAG、Agent、长期记忆和 MCP 工具调用整合在一个工程里。文章里会重点拆它的架构设计、RAG 管线和工程分层;如果你也在做 AI 应用后端、知识库问答或 Agent 平台,可以直接对照源码看完整实现。

引言

在 Agent 应用开发中,RAG(检索增强生成)几乎是标配能力------Agent 需要从外部知识库中检索相关信息来支撑推理和回答。但现实情况是,很多团队的 RAG 实现要么和业务逻辑强耦合难以复用,要么直接硬编码了特定模型 SDK 和存储后端,每次换模型、换向量库都伤筋动骨。

Cove API 的 internal/core/rag 模块提供了一个值得参考的方案:它把 RAG 全链路拆解为七个独立、可组合的子包,覆盖了从文档摄入到混合检索的完整流程,同时通过接口抽象和 Options 注入模式,将业务逻辑、模型适配和存储细节全部留给调用方。这种设计让 RAG 管道变成了一个可以按需组装的工具箱,而不是一套写死的框架。

本文将聚焦 rag 模块的整体架构、各子包的职责划分、核心接口设计以及围绕"封装与使用"的工程实践,帮助有 Agent 开发经验的读者理解这套模块的组织思路,并从中获得可迁移到自身项目的设计启发。

模块总览:七个子包的三层架构与职责边界

要设计一套可组合的 RAG 工具体系,第一个问题不是"每个环节怎么实现",而是"边界划在哪里"。Cove API 的 internal/core/rag 模块给出了一个清晰的回答:把 RAG 全链路拆成七个功能子包,外加一个模板资源包,按摄入 → 处理 → 检索三层组织,每一层只定义流程骨架,把具体能力交给调用方注入。

先建立心理模型:RAG 全链路

在深入子包之前,先用一张图建立 RAG 管道从数据到检索的完整链路。

这张图展示了核心数据流:文档和网页分别从 documentparsewebcrawl 进入管道,产出纯文本后交由 chunker 分块;同时文档中嵌入的图片经过 imagecompressimagedescribe 两阶段处理生成结构化描述;classifier 为每个 chunk 打上可选标签;最终 search 负责混合检索并返回类型安全的结果。

prompt 子包不参与数据流------它只提供模板定义和参数结构体,是 classifierimagedescribe 的共享资源。

三层架构:各司其职

按在链路中的位置,七个子包自然落入三个层次:

摄入层------把外部世界的数据变成纯文本流:

子包 入口 职责
documentparse Parser.Parse() 按文件扩展名路由到对应 Extractor,输出 Output{Text, FileExt}
webcrawl Crawler.Fetch() URL 校验 → HTTP 抓取 → Extractor 提取标题/正文,输出 Output{Title, Content, URL}

两者的共性在于:核心流程(路由、重试、安全校验)由模块定义,具体能力(格式解析、HTTP 传输、正文提取)由接口注入。

处理层------对纯文本和关联图片做加工:

子包 入口 职责
imagecompress Compressor.Compress() 尺寸判断 → 解码 → 贴白底 → 约束最长边 → JPEG 逐级编码
imagedescribe Describe() 压缩 → base64 → VisionClient 调用 → JSON 解析为 Description
chunker Chunk() 句子级切分 → token 上限合并 → 产出父子块 Chunk{Content, Children}
classifier Classify() 构建 prompt → TextClient 调用 → 解析标签 → 失败时返回空切片

处理层的子包之间不存在硬依赖------imagedescribe 通过注入的 Compressor 接口调用压缩能力,而非直接依赖 imagecompress 包。classifierTextClient 接口同样屏蔽了具体模型实现。

检索层------对已入库数据执行混合检索:

子包 入口 职责
search Searcher[T].Search() 向量召回 → BM25 召回 → 分数融合 → 可选重排 → 结果组装

search 是整个模块中抽象密度最高的子包:ESClientEmbedderRerankerFilterBuilderSourceDecoder[T] 五个接口全部由调用方注入,模块自身不绑定任何存储或模型。

为什么拆成七个子包?

如果用一个 monolithic RAG 包,调用方引入一个 parser 就得带上 search 的 ES 依赖链。这在 Agent 场景下尤其致命------不同的 Agent 运行时可能只需要其中两三个能力。

以 Cove API 的实际拆分来看,每个子包的 doc.go 都明确声明了自身不做什么:

  • documentparse:只处理文档二进制 → 纯文本,不涉及存储、索引、模型调用。
  • webcrawl:只抓取网页并提取正文,不持久化 HTML,不管理 cookie/session。
  • imagecompress:只处理图片字节,"不调用模型、不访问存储",压缩失败时返回原图而非阻断流程。
  • imagedescribe:"不依赖具体大模型 SDK、数据库或业务对象"------VisionClientCompressor 都是接口。
  • chunker:只做文本切分,不关心 chunk 之后存到哪里、怎么索引。
  • classifier:"不访问数据库、不绑定业务标签体系"------标签体系完全由 prompt 模板参数注入。
  • search:"业务过滤、索引字段含义和来源元数据解码均由调用方通过FilterBuilder、InputOption 和 SourceDecoder 注入"。

这种"不做什么"比"能做什么"更能定义模块边界。每个子包都是一个能力孤岛------它有自己完整的 Input/Output 类型、Options 配置、对外接口,但绝不对其它子包产生编译期依赖。

依赖方向:接口向下,实现向上

子包之间的协作不靠直接 import,而是靠接口注入:

每个子包都在自己的 types.go 中定义最小接口契约------比如 classifierTextClient 只有一个方法:

go 复制代码
type TextClient interface {
    Classify(ctx context.Context, prompt string, temperature float64, maxTokens int64) (string, error)
}

searchEmbedder 同样只有一个方法:

go 复制代码
type Embedder interface {
    EmbedOne(ctx context.Context, text string, dimensions int) ([]float64, error)
}

这不是巧合,而是一条贯穿全模块的设计准则:接口只定义模块真正需要的那个行为,不多不少。调用方可以用任何模型 SDK、任何存储引擎来适配这些接口,子包完全不感知具体实现。

数据流:从摄入到检索的类型演进

整个链路中数据的形态变化体现了各层的分工:

注意 search.Output[T] 中的泛型参数 T。通过 SourceDecoder[T],调用方可以将 ES 的 _source 解码为任意业务类型------比如 DocumentMetaArticleInfo 或自定义结构体。这意味着检索结果直接携带类型安全的业务元数据,调用方拿到 Output[T] 后不需要再做 interface{} 断言。

prompt:不参与数据流,但约束参数形态

prompt 子包是八个子包中唯一不暴露任何方法或结构体类型的包。它只做三件事:

  1. //go:embed *.tmpl 嵌入两个模板文件(content_classifier.tmplimage_description.tmpl);
  2. 暴露 Templates 变量和模板文件名常量;
  3. 声明 ContentClassifierData 等参数结构体,约束模板可用的变量。

它不读取、不解析、不渲染模板------这些工作由外层的 core/prompt 包完成。这种"只定义不执行"的设计让模板成为纯粹的静态资源,调用方可以自由选择渲染引擎。

边界设计的工程价值

把所有子包的边界约束放在一起看,会发现一个清晰的模式:

这四条约定了 Cove API RAG 模块的设计底线:每个子包都是一个有明确输入输出的纯函数式能力单元。调用方拥有完整的控制权------选什么模型、用什么存储、定义什么标签体系、如何降级,全部由调用方决定。

理解了全局架构之后,接下来沿着数据流向,从摄入层开始逐一深入:先看 documentparsewebcrawl 如何通过接口抽象将文档和网页统一为纯文本流。

数据摄入层:documentparse 与 webcrawl 的接口设计

RAG 的第一步永远是拿到数据。在实际场景中,数据以两种典型形态进入系统:用户上传的文档文件(PDF、DOCX、Markdown 等),以及需要从外部抓取的网页链接。这两种入口表面上是完全不同的操作,但在设计层面面临同一个问题------如何让核心流程不绑定任何具体实现。

Cove API 的答案是把摄入层拆成两个子包:documentparse 处理文档二进制 → 纯文本,webcrawl 处理 URL → 标题+正文。两个包的共同设计原则很明确:模块定义流程骨架,调用方注入具体能力。


documentparse:格式无关的文档解析

documentparse 只做一件事:接收字节数组和文件扩展名,返回纯文本。它不关心文件从哪来、文本将去哪,也不绑定任何存储或模型 SDK。

核心抽象是两个极简接口:

go 复制代码
// Extractor 定义单一文件格式的文本提取行为。
type Extractor interface {
    Extract(ctx context.Context, input Input) (string, error)
}

// TextDecoder 定义纯文本字节到字符串的解码行为。
type TextDecoder interface {
    Decode(data []byte) (string, error)
}

Extractor 负责格式解析(PDF、DOCX、HTML 等),TextDecoder 负责字符编码(UTF-8 校验、非法字节替换)。两个接口各自只有一个方法,符合接口最小化原则------每个接口只定义模块真正需要的契约。

Parser 持有 Options,后者内部维护一个 map[string]ExtractorParse 方法的流程非常直截:

go 复制代码
func (p *Parser) Parse(ctx context.Context, input Input) (*Output, error) {
    ext := normalizeExt(input.FileExt)
    extractor, ok := p.Extractors[ext]
    if ext == "" || !ok {
        return nil, fmt.Errorf("%w: %s", errUnsupportedType, ext)
    }
    text, err := extractor.Extract(ctx, Input{Data: input.Data, FileExt: ext})
    if err != nil {
        return nil, err
    }
    text = normalizeText(text)
    if text == "" {
        return nil, errEmptyContent
    }
    return &Output{Text: text, FileExt: ext}, nil
}

整个流程可以概括为三步:扩展名归一化 → 查表路由 → 提取 + 出口规整。

值得注意的一个设计细节是:extractor 只负责格式解析,空白规整(normalizeText)和空文本拦截统一在 Parse 的出口完成。这意味着任何一个自定义 Extractor 的实现者都不需要操心后处理------这是框架层的职责。

内置格式与自定义注册

NewParser 会先构造默认提取器集合,再用调用方传入的 WithExtractor 选项覆盖同扩展名的条目:

go 复制代码
func NewParser(opts ...Option) *Parser {
    decoder := utf8Decoder{}
    parser := &Parser{
        Options: Options{
            TextDecoder: decoder,
            Extractors:  map[string]Extractor{},
        },
    }
    for _, opt := range opts {
        if opt != nil {
            opt(&parser.Options)
        }
    }
    customExtractors := parser.Extractors
    parser.Extractors = defaultExtractors(parser.TextDecoder)
    for ext, extractor := range customExtractors {
        parser.Extractors[ext] = extractor
    }
    return parser
}

默认支持的格式覆盖了常见的文档类型:.txt.md/.markdown.html/.htm.docx.pdf。如果调用方需要支持 .rst.org 等自定义格式,只需实现 Extractor 接口并通过 WithExtractor 注册即可。如果想替换 PDF 的解析实现(比如换成更高精度的库),同样只需要覆盖 ".pdf" 对应的提取器。

TextDecoder 也同样可替换。默认的 utf8Decoder 会校验 UTF-8 合法性,对非法字节用空格替换------一个安全优先、静默修复的策略,而不是直接报错中断流程。

此外,extractorFunc 适配器提供了将普通函数转换为 Extractor 的便利方式:

go 复制代码
type extractorFunc func(ctx context.Context, input Input) (string, error)

func (fn extractorFunc) Extract(ctx context.Context, input Input) (string, error) {
    return fn(ctx, input)
}

内置的 PDF、DOCX、HTML 提取器都通过这个适配器挂载,调用方同样可以用它来避免定义新的结构体类型。

webcrawl:安全抓取与正文提取

webcrawl 处理的是完全不同的场景:给定一个 URL,返回网页标题和正文。流程比 documentparse 更复杂,因为它涉及网络 I/O 和安全风险。

CrawlerFetch 方法定义了一条严格的执行链路:

go 复制代码
func (c *Crawler) Fetch(ctx context.Context, input Input) (*Output, error) {
    // 前置校验:三个核心依赖必须存在
    // ...
    rawURL := strings.TrimSpace(input.URL)
    if err := c.URLGuard.Validate(ctx, rawURL); err != nil {
        return nil, err
    }
    for attempt := 0; attempt <= c.RetryCount; attempt++ {
        page, err := c.fetchOnce(ctx, rawURL)
        if err == nil {
            return c.Extractor.Extract(ctx, page)
        }
        lastErr = err
    }
    return nil, lastErr
}

整个链路分为三个阶段:URL 安全校验 → HTTP 抓取(含重试)→ 正文提取。其中抓取阶段的错误会触发重试,但提取器错误直接返回------因为提取失败通常意味着页面格式问题,重试没有意义。

URLGuard:SSRF 防护的核心

URLGuard 接口定义了 URL 安全校验能力:

go 复制代码
type URLGuard interface {
    Validate(ctx context.Context, rawURL string) error
}

默认实现 defaultURLGuardValidate 方法做了三层检查:

  1. Scheme 白名单:只允许 httphttps,拒绝 file://ftp:// 等协议。
  2. Host 解析:如果 host 本身是 IP 地址就直接校验,否则通过 Resolver 进行 DNS 解析。
  3. IP 安全检查:对解析到的每个 IP 调用 isSafeIP
go 复制代码
func isSafeIP(ip net.IP) bool {
    if ip == nil {
        return false
    }
    return ip.IsGlobalUnicast() &&
        !ip.IsPrivate() &&
        !ip.IsLoopback() &&
        !ip.IsLinkLocalUnicast() &&
        !ip.IsLinkLocalMulticast() &&
        !ip.IsMulticast() &&
        !ip.IsUnspecified()
}

这个函数排除了所有危险地址类型:内网地址(IsPrivate)、本地回环(IsLoopback)、链路本地(IsLinkLocalUnicast)、多播和未指定地址。攻击者无法通过构造指向 127.0.0.1169.254.x.x 的 URL 来探测内部服务。

更关键的是,URLGuard 不仅作用于初始请求,还作用于重定向。defaultHTTPClient 在构造 http.Client 时通过 CheckRedirect 回调,让每次重定向都经过同样的安全校验:

go 复制代码
CheckRedirect: func(req *http.Request, via []*http.Request) error {
    if len(via) >= maxRedirects {
        return http.ErrUseLastResponse
    }
    if guard != nil {
        return guard.Validate(req.Context(), req.URL.String())
    }
    return nil
},

这意味着即使攻击者从一个合法的外部 URL 重定向到内网地址,也会在校验环节被拦截。这是一种纵深防御------不在入口做一次检查就信任后续跳转。

Resolver 同样以接口形式暴露,调用方可以通过 WithResolver 注入自定义 DNS 解析器(例如接入内部 DNS 服务或缓存层),而不影响 URLGuard 的校验逻辑。

抓取与提取的分离

CrawlerOptions 中包含 HTTPClientExtractorURLGuard 三个可替换接口。默认的 HTMLExtractor 使用 golang.org/x/net/html 进行 DOM 解析,会跳过 scriptstylenoscript 等标签,只收集文本节点。标题为空时会回退到页面 URL,避免输出缺失关键元数据。

fetchOnce 中还设置了浏览器风格的 User-AgentAccept 头,减少被目标站点简单拦截的概率------这是工程实践中的细节,不影响接口抽象,但显著提高了抓取成功率。


两个子包的共同设计思路

回顾 documentparsewebcrawl,可以看到一套贯穿的设计模式:

维度 documentparse webcrawl
核心流程 normalizeExt → Extract → normalizeText Validate → fetchOnce → Extract
能力注入接口 ExtractorTextDecoder HTTPClientExtractorURLGuardResolver
适配器模式 extractorFunc extractorFunc
配置方式 Option 函数式选项 Option 函数式选项
默认实现 txt/md/html/docx/pdf HTMLExtractor + defaultURLGuard + 带重定向校验的 HTTP client

核心思想是一致的:模块定义"做什么"和"按什么顺序做",调用方决定"用什么来做"。

Parser 定义了文档解析的骨架------扩展名路由、提取、空白规整、空文本拦截------但每个格式的提取逻辑和文本解码策略完全由调用方控制。Crawler 定义了安全抓取的骨架------校验、发起请求、重试、提取------但 HTTP 客户端、安全策略和正文提取算法都是可替换的。

这种设计的结果是:两个子包都不依赖数据库、不绑定模型 SDK、不涉及存储。它们只生产纯文本或结构化输出(Output{Title, Content, URL}),下游的 chunker、classifier 等处理层子包在此基础上继续加工。


纯文本拿到之后,新的问题随之而来:文档中嵌入的图片怎么办?架构图、流程图、截图往往承载了不亚于正文的关键信息,如果直接丢弃,检索质量会大打折扣。Cove API 的做法是为图片建立一条独立的两阶段处理管线------这正是下一环节要解决的问题。

图片处理管线:imagecompress 与 imagedescribe 的两阶段协作

假如一篇待入库的技术文档里嵌入了一张系统架构图。用户搜索「服务间通信方式」时,纯文本匹配只能命中正文中零散提到的「gRPC」「消息队列」,而架构图里清晰的箭头标注、模块关系、协议说明------这些对理解系统至关重要的信息,在纯文本索引中完全不可见。

这就是 RAG 管道必须处理图片的原因:图片承载的语义信息往往是文本无法替代的。但直接拿原始图片去调视觉模型也有问题------高分辨率 PNG 动辄 10MB+,视觉模型按 token 计费,输入成本会迅速失控。

Cove API 的解决方案是把图片处理拆成两个独立子包:imagecompress 负责把图片压缩到适合模型输入的大小,imagedescribe 负责调用视觉模型生成结构化描述。两者通过接口协作,各自可替换。

下面这张图概括了完整的数据流:

压缩阶段:逐级降质,保证有结果

Compressor 的核心设计思想是「尽最大努力压缩,但绝不阻断流程」。整个 Compress 方法遵循一条清晰的降级链路:

go 复制代码
func (c *Compressor) Compress(input Input) (*Output, error) {
    out := &Output{
        Data:          input.Data,
        MIME:          mime,
        OriginalBytes: len(input.Data),
        OutputBytes:   len(input.Data),
    }
    // 第一步:小图直接放行
    if len(input.Data) <= c.TargetBytes {
        return out, nil
    }
    // 后续每一步失败都返回原图,error 为 nil
    img, _, err := image.Decode(bytes.NewReader(input.Data))
    if err != nil {
        return out, nil
    }
    // ...
}

值得注意的细节是,out 在函数开头就被初始化为携带原始数据的结构体。这意味着后续任何一步失败------解码、贴白底、缩放、编码------都只是 return out, nil。调用方始终能拿到可用的图片数据,压缩失败不会变成业务中断。

流程中的三个关键步骤各有其工程考量:

贴白底(flattenToRGB):透明 PNG 在转为 JPEG 时透明通道会被填充为黑色,这在视觉模型看来就是一团噪声。统一贴白底再编码,既避免了兼容性问题,也让模型看到的画面更接近人类预期。

约束最长边(resizeToMaxEdge):默认最长边为 1568px。这里使用 CatmullRom 算法做等比缩放,保持宽高比------宁可牺牲分辨率,也不破坏图片的语义结构。一张被压扁的架构图对模型来说是灾难。

逐级编码(encodeJPEGByQuality):这是压缩策略最精妙的部分。默认质量序列是 [85, 70, 55, 40],从高到低依次尝试:

go 复制代码
for _, quality := range qualities {
    jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality})
    last = buf.Bytes()
    if targetBytes <= 0 || len(last) <= targetBytes {
        return last, nil  // 命中目标体积,立即返回
    }
}
return last, nil  // 所有质量档位都试完,返回最后一个(最低质量)

它的逻辑是:尽可能保留视觉信息,只有在必要时才牺牲质量。而且即使所有档位都无法满足 3MB 目标,也返回最低质量的结果------永远不空手而归。

默认配置通过 Options 暴露了三个可调参数:MaxEdge(默认 1568)、TargetBytes(默认 3MB)、Qualities(默认 [85,70,55,40]),调用方可以根据模型的价格敏感度和精度要求自行调整。

描述阶段:Compressor 接口让压缩策略可替换

Describer 自身不实现压缩逻辑,而是依赖一个 Compressor 接口:

go 复制代码
type Compressor interface {
    Compress(input imagecompress.Input) (*imagecompress.Output, error)
}

默认实现当然是 imagecompress.NewCompressor(),但接口的存在意味着调用方可以注入任何实现。比如,如果未来需要针对线框图使用不同的压缩策略(更高的对比度保留、更激进的分辨率压缩),只需实现同一个接口即可替换,Describer 的核心流程完全不受影响。

同样的模式也应用在 VisionClient 上:

go 复制代码
type VisionClient interface {
    Describe(ctx context.Context, prompt string, imageBase64 string,
        mime string, maxTokens int64) (string, error)
}

Describer 不绑定任何模型 SDK。它只知道:给我一个实现了 Describe 方法的东西,我就能工作。这让调用方可以自由选择 GPT-4V、Claude Vision、Gemini 或私有部署模型,模块本身不需要任何改动。

解析与兜底:fail-open 的两层体现

Describe 方法完成压缩和模型调用后,进入 parse 阶段。这里体现了 fail-open 设计在非核心路径上的两次运用。

第一层:JSON 解析失败时,原文兜底。 视觉模型不一定严格输出 JSON,prompt 里写了「只输出 JSON」但实际行为不可控。parse 的处理方式是不阻塞:

go 复制代码
func (d *Describer) parse(answer string) *Description {
    var raw rawDescription
    if err := d.Parser.Unmarshal(answer, &raw); err != nil {
        return &Description{
            Description: truncate(answer, maxDescriptionRunes),
            Objects:     []string{},
        }
    }
    // ...
}

JSON 解析失败时,整个模型返回的原文直接塞进 Description 字段,Objects 给一个空数组。检索时至少能命中这段文本;OCR、场景标签等结构化字段虽然丢失了,但不影响最核心的语义检索能力。

第二层:字段类型不稳定时,安全规整。 即使 JSON 解析成功,模型输出的字段类型也可能不靠谱------description 可能是字符串也可能是数字,objects 可能是字符串数组也可能是单个字符串。为此,rawDescription 的所有字段都用了 any 类型,再通过 valuex 工具函数做安全转换:

go 复制代码
return &Description{
    Description: truncate(valuex.String(raw.Description), maxDescriptionRunes),
    OCRText:     truncate(valuex.String(raw.OCRText), maxOCRTextRunes),
    Objects:     valuex.StringList(raw.Objects),
    Scene:       truncate(valuex.String(raw.Scene), maxSceneRunes),
}

valuex.String 只接受 stringjson.Number,其他类型一律返回空字符串;valuex.StringList 则处理 []string[]any 两种情况,并过滤空字符串。再加上 truncate 按 rune 截断(描述和 OCR 各 2000 字符,场景标签 64 字符),确保下游拿到的永远是格式稳定的数据。

这两层兜底加起来的效果是:无论视觉模型返回什么奇怪的东西,Describe 都不会崩溃,调用方也总能拿到一个可用的 Description

Description 结构体:为检索而设计

整个管线的最终产出是一个四字段的结构体:

go 复制代码
type Description struct {
    Description string   `json:"description"`  // 主描述,便于语义检索
    OCRText     string   `json:"ocr_text"`     // 图中文字,可做关键词匹配
    Objects     []string `json:"objects"`      // 物体列表,支持标签过滤
    Scene       string   `json:"scene"`        // 场景类别,支持分类筛选
}

Prompt 模板直接对应了这个结构:

- description: 对图片内容的详细描述(一段话,尽量具体,便于后续检索) - ocr_text: 图片中出现的所有文字(没有则空字符串) - objects: 图片中的主要物体列表(字符串数组) - scene: 图片的场景类别(如:办公室、户外、文档截图、人物 等,一个词)

这个设计刻意把图片信息拆成四个维度:Description 对应语义检索场景(用户用自然语言描述图片内容),OCRText 对应关键词精确匹配(用户搜索图中出现的某个术语或产品名),ObjectsScene 对应标签过滤(「只查包含架构图的文档」或「只查有代码截图的页面」)。四个字段各司其职,下游的检索系统可以根据自己的索引结构灵活选择使用哪些维度。

从数据摄入产出的纯文本,到图片被压缩和结构化描述,整个 RAG 上游的数据准备已经就绪。但此时每个文档还是一条完整的长文本------直接入库会遇到 token 上限问题,检索粒度也太粗。下一步自然就是分块。

文本分块策略:chunker 的父子块设计与 token 管理

假设你有一个 8000 字的 API 文档,其中既有接口声明,也有配置示例和排错说明。如果直接将全文作为一个向量存入知识库,检索时会出现两个典型问题:其一,查询"如何配置超时时间"命中的向量混杂了无关章节的噪声,语义被稀释;其二,返回给 LLM 的上下文是一个 8000 字的整块,远超模型的注意力窗口,处理成本极高且容易丢失关键细节。

这正是 chunker 子包要解决的核心问题:将长文本拆成合适粒度的片段,既保留足够上下文,又保证检索精度。

父子两级分块:一个结构,两个粒度

chunker 的答案是把分块设计成父子两级结构。Chunk 类型定义非常简洁:

go 复制代码
type Chunk struct {
    Content  string
    Children []string
}

Content 是父块,代表一段较完整的上下文;Children 是同一父块内按更小 token 上限切出的子块列表。检索时用子块做向量匹配------粒度细、语义集中;但返回给 LLM 的是父块全文------上下文完整,不会因为切分丢失段落间的因果逻辑。

这种设计实际上解决了 RAG 中一个经典矛盾:召回需要小粒度以提升相关性,生成需要大粒度以保留上下文。父子块各自承担不同职责,互不妥协。

Chunk 主流程:从文本到父子块

Chunk 方法的处理流程可以用一张图概括:

流程上分四步:

  1. 用正则将全文切分为句子列表;
  2. ParentChunkTokens(默认 1024)为上限,将句子合并为父块,父块之间不做重叠;
  3. 对每个父块,再次切句后以 ChildChunkTokens(默认 512)为上限合并为子块,子块之间按重叠比例保留尾部句子;
  4. 将父块内容和子块列表封装为 Chunk 返回。

关键约束在于:子块在父块内部切出,绝不跨父块边界。这保证了每个子块命中后回查到的父块上下文是语义自洽的,不会出现"前半段讲配置、后半段讲安全"的拼接错乱。

MergeToChunk:句子级的 token 管理

MergeToChunk 是整个分块策略的引擎。它接收句子列表和 token 上限,按贪婪方式合并,逻辑上有三条分支:

正常合并:当前句的 token 数加上已累积句子不超过目标上限时,直接追加到当前块。

超限 flush:新句子会导致当前块超出上限时,先输出当前块,再根据 overlapRatio 决定是否保留尾部若干句子作为下一个块的上下文起点。对于父块,overlapRatio 为 0,flush 后清空重新开始;对于子块,默认 0.1,即保留当前块尾部 10% 的句子带入下一块。

长句拆分:如果单个句子的 token 数本身就超过目标上限------比如一段没有标点的日志输出------则进入 splitLongSentence,在 token 级别按窗口滑动切分,同样支持重叠:

go 复制代码
func (c *Chunker) splitLongSentence(sentence string, targetTokens int, overlapRatio float64) []string {
    tokens := c.tkm.Encode(sentence, nil, nil)
    step := targetTokens
    if overlapRatio > 0 {
        overlap := max(1, int(float64(targetTokens)*overlapRatio))
        step = max(1, targetTokens-overlap)
    }
    for start := 0; start < len(tokens); start += step {
        end := min(start+targetTokens, len(tokens))
        chunks = append(chunks, c.tkm.Decode(tokens[start:end]))
    }
    return chunks
}

这里直接操作 token 数组而非原始字符串,确保窗口大小精确匹配模型 tokenizer 的计算结果,避免编码差异导致的隐性超限。

子块重叠:召回连续性与存储成本的权衡

ChildOverlapRatio(默认 0.1)是子块之间重叠比例的控制器。它的工作原理是:当前子块 flush 后,不丢弃全部已累积句子,而是保留尾部 max(1, len(cur) * overlapRatio) 个句子作为下一个子块的起点。

为什么需要重叠?考虑一段跨越两个子块边界的论述------"如上所述,该配置项存在三种模式......第一种模式适用于......"。如果不重叠,检索"第一种模式"时可能只命中后半段,丢失了前置的"如上所述"和"三种模式"的总括信息。10% 的重叠让相邻子块共享边界附近的句子,有效缓解这种边界截断问题。

当然,重叠会增加存储成本。每个子块的尾部句子会在相邻子块中重复出现,向量索引也会存储重复的 embedding。默认 10% 是在召回连续性和存储开销之间的一个经验平衡点,调用方可以根据文档特征自行调整------知识库类文档可以设得更高,新闻类短平快内容可以设得更低甚至为 0。

Options 模式:一切可配置

chunker 不将任何分块策略写死。所有参数通过 Options 结构体和 With* 函数注入:

go 复制代码
type Options struct {
    ChildChunkTokens  int
    ParentChunkTokens int
    ChildOverlapRatio float64
    SentenceRegex     *regexp.Regexp
    TokenEncodingName string
}
  • ChildChunkTokens / ParentChunkTokens:子块和父块的 token 上限,默认 512 / 1024。如果模型上下文窗口更大,可以调高父块上限以提供更完整上下文。
  • ChildOverlapRatio:子块重叠比例,默认 0.1。
  • SentenceRegex:句子切分正则,默认已覆盖中英文常见标点(。!?.!? )。对于代码、表格等非自然语言文本,调用方可以注入自定义正则。
  • TokenEncodingName:tiktoken 编码名称,默认 cl100k_base(与 GPT-4 / GPT-3.5 一致)。如果使用其他模型,更换编码名即可对齐 token 计数。

NewChunker 通过 Functional Options 模式组装配置:

go 复制代码
chunker := chunker.NewChunker(
    chunker.WithParentChunkTokens(2048),
    chunker.WithChildChunkTokens(768),
    chunker.WithChildOverlapRatio(0.15),
)

编码名无法加载时直接 panic------这属于初始化阶段的配置错误,应该在启动或测试中暴露,而非在运行时静默降级。除此以外,Chunker 初始化后可安全复用,不在分块过程中修改公开配置。

与上下游的衔接

chunker 的输入是经过图片描述管线处理后的纯文本------此时文档中的图片已被替换为结构化的文字描述,整份文档就是一个待分块的文本流。chunker 不关心文本来源,也不关心下游存储方式;它只负责将文本拆成父子块并返回 []*Chunk

分块完成后,每个子块就是一个检索单元。但即便粒度合适,检索精度仍然受限于"用户查询的用词"与"文档内容的用词"之间的语义鸿沟。一个自然的增强手段是为每个 chunk 打上分类标签------比如"配置说明""接口定义""排错指南"------让检索时可以在向量匹配之外叠加类别过滤。这正是 classifier 子包承担的角色:在分块产出 chunk 列表后,可选地为它们附加结构化标签,进一步提升检索精度。

轻量分类与降级保护:classifier 的 fail-open 实践

RAG 检索中,标签分类到底是锦上添花还是必需品?这个问题没有标准答案。在构建知识库时,给每个 chunk 打上主题标签确实能提升过滤效率------检索时先按标签缩小候选集,再交给向量模型做语义匹配,精度和延迟都会更好。但反过来看,如果标签打错了,反而可能把正确答案排除在召回范围之外。而如果分类本身依赖大模型调用,多出来的延迟和成本是否值得,也取决于场景。

Cove API 的 classifier 包对这个问题的回答是:分类是可选的增强能力,不是强制步骤。 它的设计哲学可以浓缩为一句话------做好该做的事,出错时安静退场,绝不拖累主流程。

整体流程:从文本到标签

classifier 的职责非常聚焦:接收一段文本和已有的标签池,调用文本模型输出最多两个宽泛主题标签。整个过程被封装在 Classify 这一个方法中。

三个核心步骤环环相扣,但每一步都埋着降级逻辑。

最小接口:TextClient 的一方法契约

classifier 不依赖任何具体的 LLM SDK。它定义的 TextClient 接口只有一个方法:

go 复制代码
type TextClient interface {
    Classify(ctx context.Context, prompt string, temperature float64, maxTokens int64) (string, error)
}

这个接口的妙处在于:它对模型调用的抽象停留在"给一段 prompt,返回一段文本"的最原始粒度,不关心模型是 OpenAI、Claude 还是本地部署的开源模型。classifier 自身不知道也不关心消息格式、多轮对话、流式输出等概念------那些是调用方的事。

如果调用方使用的是 Cove API 内部的 core LLM 客户端,可以通过 WithClient 注入,classifier 内部会通过 llmTextClient 适配器自动完成转换:

go 复制代码
type llmTextClient struct {
    client corellm.Client
}

func (c llmTextClient) Classify(ctx context.Context, prompt string,
    temperature float64, maxTokens int64) (string, error) {
    return c.client.Invoke(ctx,
        []*corellm.Message{{Role: corellm.UserRole, Content: prompt}},
        corellm.WithTemperature(temperature),
        corellm.WithMaxTokens(maxTokens),
    )
}

而如果调用方使用的是外部模型或自定义客户端,只需实现 TextClient 接口即可,通过 WithTextClient 直接注入。两种注入方式同时存在时,后传的 option 生效,语义清晰。

这种设计让 classifier 可以独立于公司内部的基础设施演进------哪天换了模型供应商,classifier 一行代码都不用改。

模板注入:让标签体系由调用方定义

分类器的 prompt 同样不硬编码。默认模板 content_classifier.tmpl 使用了 Go template 语法:

text 复制代码
你是一个内容分类助手。请阅读下面的文本,为它打 1 到 2 个宽泛的中文主题标签。

【已有标签】(请优先复用,只要语义吻合就直接选用,不要造近义词/同义词):
{{ .Existing }}

规则:
- 优先从【已有标签】中选择;只有当已有标签都明显不合适时,才创造 1 个新的宽泛标签。
...
文本内容:
{{ .Content }}

模板参数由 ContentClassifierData 结构体约束:

go 复制代码
type ContentClassifierData struct {
    Existing string  // 调用方已有标签列表拼接后的文本
    Content  string  // 裁剪后的待分类正文
}

这个设计的意义在于:业务标签体系完全由调用方控制。如果你是一家法律科技公司,希望标签在"合同法""劳动法""知识产权"等维度上分布,你只需要在 Input.ExistingTags 中传入这些候选标签,模型就会优先复用它们。如果你想完全替换 prompt------比如改用英文分类、调整标签粒度------直接调用 WithPrompt 传入自定义 prompt 即可,此时模板渲染会被关闭。

buildPrompt 还做了一个容易被忽略的工程处理:用 valuex.TruncateRunes 将正文截断到 1500 个 rune(可通过 WithSnippetRunes 调整)。这既控制了 prompt token 消耗,也避免了某些模型对过长输入的不稳定表现。

fail-open:静默降级比抛错更工程

classifier 最值得讲的设计,是它对失败的几乎"无所谓"态度。

Classify 的关键路径:

go 复制代码
// 分类是辅助能力:模型调用失败不能阻断文档主流程。
answer, err := client.Classify(ctx, prompt, c.Temperature, c.MaxTokens)
if err != nil {
    return &Result{Tags: []string{}}, nil
}
return &Result{Tags: c.parseTags(answer)}, nil

模型调用失败时,返回的是 &Result{Tags: []string{}}, nil------空标签 + 无错误。parseTags 内部同理:JSON 数组提取失败、Unmarshal 失败、标签规整后全部为空------全部返回空切片,不产生 error。

整个 Classify 只在两种情况下返回 error:client 未配置,或 Parser 未配置。这两者都属于配置错误而非运行时故障------它们是调用方的 bug,应该在启动阶段就被发现并修复。运行时的一切异常------网络抖动、模型超时、输出格式异常、标签内容不符合预期------全部被静默吸收。

这种策略在工程上被称为 fail-open:失败时保持通路开放,让系统继续运行,而不是中断整条链路。与之相对的是 fail-closed------一遇到错误就返回 error,由上游决定如何处理。

在 RAG 管道中,classifier 处于非关键路径。chunk 有没有标签,不影响它能不能被检索到;向量召回和 BM25 召回都不依赖标签。如果因为分类失败而阻塞整个文档的入库流程,那就是为了一项辅助能力牺牲了核心能力------显然不划算。

换个角度看,fail-open 也让调用方的代码更干净:

go 复制代码
result, _ := classifier.Classify(ctx, input)
// result.Tags 可能是 ["技术", "AI"] 也可能是 [],但代码不需要分支处理
chunk.Tags = result.Tags

调用方不需要 try-catch、不需要判断 error、不需要考虑降级逻辑。分类成功就拿到标签,分类失败就拿到空切片,对下游的影响只是"标签字段为空"------这在检索时最多意味着标签过滤条件不生效,检索仍然可以走纯语义路径。

标签解析的防御性设计

parseTags 内部的防御层次也值得展开。模型输出天然不稳定------有时返回纯 JSON 数组,有时用 markdown 代码块包裹,有时在数组前后加解释文字。extractJSONArray 的处理策略很务实:

go 复制代码
func extractJSONArray(answer string) string {
    text := strings.TrimSpace(answer)
    if strings.HasPrefix(text, "```") {
        text = strings.Trim(text, "`")
        text = strings.TrimSpace(strings.TrimPrefix(text, "json"))
    }
    start := strings.Index(text, "[")
    end := strings.LastIndex(text, "]")
    if start == -1 || end < start {
        return ""
    }
    return text[start : end+1]
}

先去除 markdown 包裹,再用 [] 定位 JSON 数组。不要求模型输出严格 JSON,只要包含可识别的数组片段就能解析。

解析后的规整同样保守:每个标签截断到 16 个字符,非字符串元素(模型偶尔会输出数字或嵌套对象)被转换为字符串后过滤,最终只保留最多 2 个标签。这些限制不是随意的------它们对应了 prompt 模板中的约束("每个标签 2 到 16 个字符,最多输出 2 个"),形成了一层"即使模型不听话也不会把脏数据写入下游"的兜底。

Options 的双层配置

classifier 的 Options 设计延续了全模块的一致性模式,但多了一层值得注意的细节:构造级配置和请求级配置的分层。

构造级配置在 NewClassifier 时通过 Option 函数注入:

go 复制代码
classifier := classifier.NewClassifier(
    classifier.WithClient(llmClient),
    classifier.WithTemperature(0.3),
    classifier.WithMaxTokens(150),
)

请求级配置在每次 Classify 时通过 InputOption 函数注入:

go 复制代码
result, _ := classifier.Classify(ctx, input,
    classifier.WithInputClient(anotherLLMClient),
)

请求级 client 优先于构造级。这意味着同一个 Classifier 实例可以用默认模型处理大部分请求,而在特定场景(比如某些 chunk 需要更强模型)下临时切换。这种双层设计在不引入额外复杂度的情况下提供了足够的灵活性。


所有数据处理完毕、标签也打好了,chunk 现在可以写入向量数据库和全文索引。接下来就进入整个 RAG 管道真正对外产生价值的环节------检索。search 包如何把向量召回和 BM25 召回融合成统一的检索结果,如何通过重排进一步提升精度,正是下一部分的核心。

混合检索核心:search 包的双路召回、融合与重排

假设用户在某个 Agent 应用中输入:「如何配置 HTTPS 证书?」这条查询进入 Cove API 的 search 包后,将经历一条精心编排的多阶段检索流水线。下面以这次查询为例,完整追踪它从文本到最终结果的路径。

整个流程可以拆为四个阶段:双路召回、分数融合、可选重排、结果组装。每个阶段都承载了明确的工程考量。

双路召回:语义与关键词的互补

搜索的第一个关键决策是同时发起两路召回,而非二选一。

向量召回通过 Embedder.EmbedOne 将查询文本转为 1024 维向量,构造 ES knn 查询命中 vector 字段。它擅长捕捉「HTTPS 证书」与「TLS 配置」「SSL 证书安装」之间的语义关联,但也可能在查询包含精确术语时漏掉严格匹配的文档。

BM25 召回则直接对 content 字段执行 match 查询,基于词频和逆文档频率打分。「HTTPS」和「证书」作为关键词的精确命中能得到可靠的高分,不会因语义漂移被稀释。

两路召回共享同一组 filter------FilterBuilder 返回的过滤条件会同时注入 vectorQuerybm25Query

go 复制代码
// vectorQuery 和 bm25Query 共享 baseFilter
knnResp, _ := s.es.Search(ctx, s.Index, vectorQuery(queryVector, recallSize, s.KnnOversample, baseFilter))
bm25Resp, _ := s.es.Search(ctx, s.Index, bm25Query(req.Query, recallSize, baseFilter))

collectHits 将两路结果按 _id 合并到同一个 map,同时分别记录向量分数和 BM25 分数。同一文档在两侧都命中时,两套分数都被保留,留给后续融合阶段处理。

在融合之前,还有一个可选的门控步骤:MinVectorScore。当调用方设置该阈值时,只有 ES cosine 原始相关度达到门槛的候选才能进入融合。ES 的 cosine KNN 将分数映射为 (1 + cos) / 2,因此 filterByMinVectorScore 需要先还原真实 cosine 再比较:

go 复制代码
cos := 2*score - 1
if cos < minScore {
    continue
}

这个门控适合精确搜索场景------用户想要与查询高度相关的文档,而非「大致相关」的内容。

分数归一化与加权融合

两路召回的分数尺度完全不同:向量分数是 cosine 相似度(映射后介于 0 到 1),BM25 分数则由词频统计决定,没有固定上界。直接相加毫无意义,必须先归一化。

Normalize 函数对每一路分数独立执行 min-max 归一化,将所有分数映射到 0, 1 区间:

go 复制代码
for id, score := range scores {
    out[id] = (score - lo) / (hi - lo)
}

这里有一个细节:当一路中所有候选分数完全相同(hi - lo < 1e-9)时,每个条目直接返回 1,避免分母为零导致融合时全部塌缩为 0。

归一化完成后,fuseScores 执行加权线性组合:

go 复制代码
fused[id] = vectorWeight*vecScores[id] + bm25Weight*bmScores[id]

默认权重为 向量 0.6、BM25 0.4(defaultVectorWeight / defaultBM25Weight),偏向语义匹配但仍保留关键词匹配的影响力。这个比例由 WithVectorWeightWithBM25Weight 在构造时配置,允许调用方按业务场景调整------例如法律文档检索可能调高 BM25 权重以保证术语精确匹配。

融合后的结果由 rankedIDs 按分数降序排列,分数相同时按 id 升序作为稳定 tie-break。排名列表的前 max(topK, recallSize) 个候选进入下一阶段。

重排阶段的 fail-open 设计

融合排序已经给出了一个可用结果,但 reranker(通常是一个更精准的 Cross-Encoder 模型)可以对顶部候选做二次精排。

applyRerank 的设计将 reranker 明确定位为可选增强而非必备组件。这体现在三个层面:

第一,配置可分两层。 构造级的 WithReranker 设置默认 reranker,请求级的 WithInputReranker 可在单次调用中覆盖。resolveRerankConfig 将两层配置合并,请求级优先级高于构造级:

go 复制代码
config := rerankConfig{
    enabled:    true,
    reranker:   s.Reranker,
    windowSize: s.RerankWindowSize,
    ...
}
if req.reranker != nil {
    config.reranker = req.reranker
}

WithInputRerankEnabled(false) 甚至可以在请求级完全关闭重排,即使构造级已配置 reranker。

第二,窗口截取控制成本。 只有融合排序后的前 windowSize 个候选被送入 reranker,默认窗口为 max(topK, recallSize)。调用方可以通过 WithRerankWindowSize 缩小窗口以降低延迟和模型调用成本。

第三,也是最重要的------fail-open。 当 config.reranker.Rerank() 返回错误时,applyRerank 不直接向上抛出:

go 复制代码
reranked, err := config.reranker.Rerank(ctx, req.Query, documents, config.topK)
if err != nil {
    if config.failOpen {
        return candidateIDs, nil, nil  // 回退到融合排序
    }
    return nil, nil, err
}

defaultRerankFailOpentrue,意味着 reranker 挂掉时,系统静默退回到第一阶段的融合排序结果,检索本身不受影响。这个设计的工程含义很明确:reranker 是一个辅助服务,不应成为检索的硬依赖。调用方如果希望强制重排(例如在严格评测场景中),可以通过 WithRerankFailOpen(false) 关闭回退。

mapRerankResults 还做了额外的防御:越界下标、重复下标、低于 minScore 的结果都会被静默丢弃------外部 reranker 的行为不被完全信任,core 层自己做兜底校验。

子块命中与父块回查

检索结果进入 resultsForIDs 组装阶段。这里有一个容易被忽略但实际影响上下文完整性的机制:parent chunk 回查。

在 chunker 的父子块设计中,子块(child)被索引以支持细粒度召回,但子块内容往往是不完整的句子片段。resolveParentContent 在组装结果时检查每条命中的 parent_id 字段:

go 复制代码
parentID := valuex.String(src["parent_id"])
if parentID == "" {
    return valuex.String(src["content"]), nil
}
resp, err := s.es.Search(ctx, s.Index, map[string]any{
    "size": 1,
    "query": map[string]any{
        "bool": map[string]any{
            "filter": []any{
                map[string]any{"term": map[string]any{"chunk_id": parentID}},
            },
        },
    },
})

如果当前命中是子块且存在父块,则通过 ES 的 term 查询回查父块,用父块的完整内容替换 Output.Content。如果父块查询失败或父块内容为空,则退回使用子块自身的 content 字段。

这意味着上层调用方拿到的 Output.Content 始终是完整上下文------即使命中发生在子块级别。对于 Agent 场景的下游 LLM 调用,这直接决定了生成回答的信息完整性。

贯穿全流程的配置分层

search 包的配置体系值得单独关注。Options(构造级)定义 Searcher 的长期配置------索引名、维度、权重、默认 reranker;InputOption(请求级)通过一系列 WithInput* 函数覆盖单次请求行为。这种双层设计让同一 Searcher 实例可以在不同请求中使用不同的 embedder、reranker 甚至完全关闭重排,而无需创建多个实例。

NewSearcher 的默认值可以看到包的保守设计哲学:默认 topK=5、recallSize=20、embeddingDim=1024、rerankFailOpen=true------所有参数都倾向于「先跑起来,再按需调优」。

整个 search 包大量运用了泛型 Searcher[T]SourceDecoder[T] 来实现类型安全的业务元数据解码,EmbedderRerankerESClient 等核心接口则各只有一个方法------这些贯穿全模块的接口抽象和泛型设计模式,在后文将单独展开总结。

Mermaid 工具遇到环境限制,我将直接使用语法正确的 Mermaid 代码。现在开始编写章节。


贯通设计:Options 注入、接口抽象与泛型 SearcherT

如果让你设计一个 RAG 框架,你会怎么做?是在每个子模块里硬编码模型 SDK 调用、写死分块参数、用 interface{} 搬运检索结果------还是把这些选择权交还给调用方?Cove API 的 rag 模块选择了后者。当你横向看完七个 sub-package 的代码,会发现它们共享同一套设计基因:Options 注入控制行为、最小接口抽象解耦外部依赖、泛型承载类型安全、fail-open 保护非关键路径。这四个模式不是某个子包的局部选择,而是贯穿全模块的架构约定。

Options 模式:构造级配置与请求级配置的双层分离

所有子包无一例外地采用 type Options struct + type Option func(*Options) + With* 函数簇的模式。chunker 的 WithChildChunkTokens、classifier 的 WithTemperature、documentparse 的 WithExtractor、webcrawl 的 WithTimeout------命名不同,结构完全一致。这不是巧合,而是一种刻意的工程约束:调用方永远通过函数式 Option 注入配置,模块内部永远不假设配置来源。

但真正的设计亮点不在"都用 Options",而在 search 包对"构造级"和"请求级"的明确切分。

search 包有两套配置体系:Option 用于 NewSearcher 构造时注入,影响 Searcher 的整个生命周期;InputOption 用于每次 Search 调用时注入,只影响当次请求。

go 复制代码
// 构造级:创建 Searcher 时通过 Option 注入,长期持有
searcher := search.NewSearcher[MySource](esClient,
    search.WithIndex("my_cove_chunks"),     // 索引名------构造级
    search.WithEmbedder(myEmbedder),         // 向量化客户端------构造级
    search.WithReranker(myReranker),         // 重排器------构造级
    search.WithFilterBuilder(myFilter),      // 过滤构造器------构造级(但延迟执行)
)

// 请求级:每次 Search 调用时通过 InputOption 注入,单次生效
results, _ := searcher.Search(ctx, "什么是 RAG?",
    search.WithTopK(10),                     // 返回数量------请求级
    search.WithFilters(tenantFilter),        // 本次筛选条件------请求级
    search.WithInputRerankEnabled(false),    // 本次跳过重排------请求级
)

为什么 WithIndex 是构造级而 WithTopK 是请求级?因为索引名是 Searcher 的物理锚点,一旦创建就不该在运行时切换;而返回数量、过滤条件、是否启用重排这些参数天然随查询语义变化。这个区分看似琐碎,实际上把"这个 Searcher 连到哪里"和"这次搜索要什么"两个关注点干净拆开了。

下面的流程图展示了双层 Options 的注入和决议路径:

classifier 也采用同样的双层设计:WithClient 设置长期 client,WithInputClientClassify 时覆盖。不过 chunker、documentparse、webcrawl 只用了单层构造级 Options------因为它们的行为不随单次请求语义变化,强行拆分反而过度设计。

接口最小化:每个接口只定义一个方法

如果你打开各子包的 types.go,会发现一个规律:几乎每个接口只有一个方法。

子包 接口 唯一方法
search Embedder EmbedOne(ctx, text, dims) ([]float64, error)
search Reranker Rerank(ctx, query, docs, topN) ([]RerankResult, error)
search ESClient Search(ctx, index, query) (map[string]any, error)
classifier TextClient Classify(ctx, prompt, temp, maxTokens) (string, error)
documentparse Extractor Extract(ctx, input) (string, error)
documentparse TextDecoder Decode(data) (string, error)
webcrawl HTTPClient Do(req) (*http.Response, error)
webcrawl Extractor Extract(ctx, page) (*Output, error)
webcrawl URLGuard Validate(ctx, rawURL) error
webcrawl Resolver LookupIP(ctx, host) ([]net.IP, error)
imagedescribe VisionClient Describe(ctx, prompt, imgBase64, mime, maxTokens) (string, error)
imagedescribe Compressor Compress(input) (*Output, error)

这不是巧合,而是刻意的最小化设计。以 Embedder 为例:

go 复制代码
type Embedder interface {
    EmbedOne(ctx context.Context, text string, dimensions int) ([]float64, error)
}

只定义 EmbedOne,不定义 EmbedBatch、不定义模型类型枚举、不定义 token 计数。因为 search 包只需要"把一条查询文本变成向量"这一个能力。批量向量化是下游业务的事,token 计数是 chunker 的事------模块不管不该管的事。

同样的,classifier 的 TextClient 只暴露 Classify 一个方法:

go 复制代码
type TextClient interface {
    Classify(ctx context.Context, prompt string, temperature float64, maxTokens int64) (string, error)
}

classifier 不关心这个 client 背后是 OpenAI 还是本地模型、是否支持流式输出、conversation history 怎么管理。它只需要"给我一段 prompt,还我一段文本"。

这种最小接口的工程价值在于:调用方适配成本极低。想接入一个新的 embedding 服务?实现一个方法就行。想替换视觉模型?实现 VisionClient 的一个方法就行。模块不绑定任何 SDK,不引入任何第三方模型依赖。

泛型 SearcherT:类型安全的业务元数据解码

检索结果的 _source 是 ES 返回的 map[string]any。如果直接把 map[string]any 抛给调用方,业务代码就要到处做类型断言,既繁琐又危险。Cove API 的方案是:让 Searcher 本身泛型化,让调用方通过 SourceDecoder[T] 定义自己的解码逻辑。

go 复制代码
// SourceDecoder 把 ES _source 解码成调用方需要的类型
type SourceDecoder[T any] func(src map[string]any) (T, error)

// Searcher 不理解业务字段,业务元数据通过 SourceDecoder 解码到 Output.Source
type Searcher[T any] struct {
    Options
    es            ESClient
    sourceDecoder SourceDecoder[T]
}

// Output 的 Source 字段直接是调用方定义的类型 T
type Output[T any] struct {
    ID          string
    Content     string
    Score       float64
    RerankScore *float64
    Source      T          // 不是 interface{},是 T
}

这个设计的精妙之处在于 NewSearcher 中的类型恢复:

go 复制代码
func NewSearcher[T any](esClient ESClient, opts ...Option) *Searcher[T] {
    searcher := &Searcher[T]{...}
    for _, opt := range opts {
        opt(&searcher.Options)
    }
    // WithSourceDecoder 在 Options 中以 any 存储,这里通过类型断言恢复到具体类型
    if decoder, ok := searcher.Options.sourceDecoder.(SourceDecoder[T]); ok {
        searcher.sourceDecoder = decoder
    }
    return searcher
}

由于 Go 泛型的限制,WithSourceDecoder[T] 的泛型参数无法直接传导到 Options.sourceDecoder 字段(Options 是非泛型结构体),因此采用了"存储为 any,构造时类型断言恢复"的桥接方式。调用方对此完全无感------只要 NewSearcher 的类型参数和 WithSourceDecoder 的类型参数一致,一切类型安全:

go 复制代码
type MySource struct {
    DocID    string `json:"doc_id"`
    Category string `json:"category"`
}

decoder := func(src map[string]any) (MySource, error) {
    // 自定义解码逻辑
}

searcher := search.NewSearcher[MySource](esClient,
    search.WithSourceDecoder(decoder),
)

results, _ := searcher.Search(ctx, "query")
// results[0].Source 的类型是 MySource,不是 interface{}
fmt.Println(results[0].Source.Category) // 类型安全,IDE 有补全

resultsForIDs 方法在组装结果时调用 sourceDecoder,一旦解码失败会直接返回错误而不是静默丢弃------这意味着业务元数据的完整性是可验证的。

fail-open:非关键路径的统一降级哲学

RAG 管道中有些环节是"没它不行"的------比如向量化查询、ES 检索------这些环节失败必须返回错误。但更多环节属于"锦上添花":分类标签、图片压缩、重排。这些辅助能力失败时,管道应该继续运转还是整体报错?

Cove API 的选择是统一的 fail-open:辅助能力失败时静默降级,不阻断主流程。

classifier 最典型。在 Classify 方法中,模型调用失败直接返回空标签:

go 复制代码
// 分类是辅助能力:模型调用失败不能阻断文档主流程。
answer, err := client.Classify(ctx, prompt, c.Temperature, c.MaxTokens)
if err != nil {
    return &Result{Tags: []string{}}, nil  // 空标签,error 为 nil
}

不光是模型调用失败,JSON 解析失败同样降级------parseTagsextractJSONArray 返回空串或 Unmarshal 失败时返回空切片,不抛错误。

imagecompress 遵循同样的原则。Compress 方法中,解码失败或重编码失败都返回原图:

go 复制代码
// 解码失败通常意味着格式暂不支持或数据损坏;压缩器降级返回原图,不把辅助失败升级为业务错误。
img, _, err := image.Decode(bytes.NewReader(input.Data))
if err != nil {
    return out, nil  // 返回原图,error 为 nil
}

imagedescribe 的 parse 方法在 JSON 解析失败时,用模型原文作为兜底描述:

go 复制代码
if err := d.Parser.Unmarshal(answer, &raw); err != nil {
    // 模型没有按 JSON 返回时,将原文作为描述兜底,避免上层丢失可检索文本。
    return &Description{
        Description: truncate(answer, maxDescriptionRunes),
        Objects:     []string{},
    }
}

search/rerank 则更进一步------不仅默认 fail-open,还把选择权交给了调用方:

go 复制代码
// rerank 是可选增强;fail-open 时保留第一阶段融合结果,避免辅助服务阻断检索。
if config.failOpen {
    return candidateIDs, nil, nil  // 回退到融合排序
}
return nil, nil, err  // failOpen=false 才暴露错误

RerankFailOpen 默认值为 true,调用方可以通过 WithRerankFailOpen(false) 显式关闭。这种设计在"安全默认"和"可观测性"之间找到了平衡:生产环境默认降级保可用,调试时可以关闭降级来排查 reranker 问题。

这四个模式不是独立存在的。把它们放在一起看:Options 模式让调用方控制所有行为参数,最小接口让调用方自由替换外部依赖,泛型 SearcherT 让调用方以类型安全的方式拿到业务数据,fail-open 让调用方可以安全地把辅助能力当作可选增强而非必需品。它们共同表达了一个设计立场------这套 RAG 模块不是黑盒引擎,而是一组调用方握有全部控制权的可组合工具。

正是这些设计模式------模块化拆解、接口抽象、Options 注入、fail-open 降级------共同构成了一套可组合的 RAG 工具体系,而非一个黑盒引擎。

结论

Cove API 的 rag 模块展示了一种将 RAG 能力模块化、可组合化的实践思路。它不试图提供"开箱即用的一体化 RAG 引擎",而是将管道中的每一步------文档解析、文本分块、图片处理、内容分类、网页抓取、混合检索------都封装为独立、低耦合的子包,每个子包通过接口契约与调用方交互,不绑定具体的模型 SDK、数据库或业务标签体系。

这种设计带来的好处是显而易见的:调用方可以按需选取子包,用 Options 模式注入自己的适配器,在保持核心流程不变的前提下灵活替换底层实现。同时,模块在辅助能力(如分类、重排)上采用了 fail-open 策略,确保非关键路径的失败不会中断主流程,体现了工程上对鲁棒性的重视。

对于有 Agent 开发经验的工程师来说,这套模块的最大启发或许在于:RAG 管道的"封装"不一定要封装成一个黑盒,把每个环节封成可替换的积木块,往往比一个精巧但难以修改的整体方案更有生命力。

项目地址

本文里的实现已经整理在 GitHub:

  • GitHub:github.com/chenyang-zz...
  • 技术栈:Go / Gin / PostgreSQL / Elasticsearch / Neo4j / Redis / asynq
  • AI 能力:SSE 流式对话、RAG、Agent、长期记忆、MCP、Anthropic / OpenAI 多 Provider
  • 工程能力:分层架构、异步任务、OpenAPI 生成、Repository 生成、Docker Compose 本地启动

如果你正在做 AI 应用后端、企业知识库、Agent 平台,或者想参考一套 Go 版本的 RAG + Agent 工程结构,可以直接 clone 跑起来看。项目还在持续迭代,欢迎 Star,也欢迎通过 Issue 交流真实使用场景。

相关推荐
love530love1 小时前
AI Agent + 本地 ComfyUI 无头模式实战:关闭 IDE 后 AI 独立重启并完成图文生成
ide·人工智能·windows·python·音视频·agent·devops
FriendshipT1 小时前
Ultralytics:解读Attention模块
人工智能·pytorch·python·深度学习·目标检测
生活爱好者!1 小时前
AI加持的笔记工具,比备忘录好用,NAS一键部署blinko
人工智能·笔记
IT_陈寒1 小时前
SpringBoot自动配置没生效?你可能漏了这个注解
前端·人工智能·后端
SilentSamsara2 小时前
模型部署方案选型:REST/gRPC/批量推理/边缘部署的场景决策
人工智能·深度学习·算法·机器学习
多年小白2 小时前
第八篇 模拟面试套卷
人工智能·ai·面试·职场和发展
thubier(段新建)2 小时前
OWTB 3PL 核心主流程与行业落地方案
大数据·人工智能
@realXuan2 小时前
人工智能AI编程 Agent 入门系列教程之 Claude Code 是什么
人工智能·python·ai编程
智脑API平台2 小时前
告警太多等于没告警:DevOps 用 Claude Code 做日志归因和发布复盘
运维·人工智能·devops·codex