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

作者:袖梨 2026-07-07

引言

在 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的共享资源。

三层架构:各司其职

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

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

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

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

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

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

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

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

子包入口职责
searchSearcher[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只有一个方法:

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

searchEmbedder同样只有一个方法:

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。

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

// 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方法的流程非常直截:

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选项覆盖同扩展名的条目:

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.Extractorsparser.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的便利方式:

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方法定义了一条严格的执行链路:

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 安全校验能力:

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

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回调,让每次重定向都经过同样的安全校验:

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,可以看到一套贯穿的设计模式:

维度documentparsewebcrawl
核心流程normalizeExt → Extract → normalizeTextValidate → fetchOnce → Extract
能力注入接口ExtractorTextDecoderHTTPClientExtractorURLGuardResolver
适配器模式extractorFuncextractorFunc
配置方式Option函数式选项Option函数式选项
默认实现txt/md/html/docx/pdfHTMLExtractor + 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方法遵循一条清晰的降级链路:

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 为 nilimg, _, 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],从高到低依次尝试:

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接口:

type Compressor interface {Compress(input imagecompress.Input) (*imagecompress.Output, error)}

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

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

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的处理方式是不阻塞:

func (d *Describer) parse(answer string) *Description {var raw rawDescriptionif 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工具函数做安全转换:

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 结构体:为检索而设计

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

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类型定义非常简洁:

type Chunk struct {ContentstringChildren []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 级别按窗口滑动切分,同样支持重叠:

func (c *Chunker) splitLongSentence(sentence string, targetTokens int, overlapRatio float64) []string {tokens := c.tkm.Encode(sentence, nil, nil)step := targetTokensif 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*函数注入:

type Options struct {ChildChunkTokensintParentChunkTokens intChildOverlapRatio float64SentenceRegex *regexp.RegexpTokenEncodingName string}

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

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

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接口只有一个方法:

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适配器自动完成转换:

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 语法:

你是一个内容分类助手。请阅读下面的文本,为它打 1 到 2 个宽泛的中文主题标签。【已有标签】(请优先复用,只要语义吻合就直接选用,不要造近义词/同义词):{{ .Existing }}规则:- 优先从【已有标签】中选择;只有当已有标签都明显不合适时,才创造 1 个新的宽泛标签。...文本内容:{{ .Content }}

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

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

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

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

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

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

Classify的关键路径:

// 分类是辅助能力:模型调用失败不能阻断文档主流程。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 也让调用方的代码更干净:

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

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

标签解析的防御性设计

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

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函数注入:

classifier := classifier.NewClassifier(classifier.WithClient(llmClient),classifier.WithTemperature(0.3),classifier.WithMaxTokens(150),)

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

result, _ := classifier.Classify(ctx, input,classifier.WithInputClient(anotherLLMClient),)

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

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

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

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

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

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

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

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

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

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

// vectorQuery 和 bm25Query 共享 baseFilterknnResp, _ := 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 再比较:

cos := 2*score - 1if cos < minScore {continue}

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

分数归一化与加权融合

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

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

for id, score := range scores {out[id] = (score - lo) / (hi - lo)}

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

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

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将两层配置合并,请求级优先级高于构造级:

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不直接向上抛出:

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字段:

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 注入、接口抽象与泛型 Searcher[T]

如果让你设计一个 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调用时注入,只影响当次请求。

// 构造级:创建 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,会发现一个规律:几乎每个接口只有一个方法。

子包接口唯一方法
searchEmbedderEmbedOne(ctx, text, dims) ([]float64, error)
searchRerankerRerank(ctx, query, docs, topN) ([]RerankResult, error)
searchESClientSearch(ctx, index, query) (map[string]any, error)
classifierTextClientClassify(ctx, prompt, temp, maxTokens) (string, error)
documentparseExtractorExtract(ctx, input) (string, error)
documentparseTextDecoderDecode(data) (string, error)
webcrawlHTTPClientDo(req) (*http.Response, error)
webcrawlExtractorExtract(ctx, page) (*Output, error)
webcrawlURLGuardValidate(ctx, rawURL) error
webcrawlResolverLookupIP(ctx, host) ([]net.IP, error)
imagedescribeVisionClientDescribe(ctx, prompt, imgBase64, mime, maxTokens) (string, error)
imagedescribeCompressorCompress(input) (*Output, error)

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

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

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

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

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

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

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

泛型 Searcher[T]:类型安全的业务元数据解码

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

// SourceDecoder 把 ES _source 解码成调用方需要的类型type SourceDecoder[T any] func(src map[string]any) (T, error)// Searcher 不理解业务字段,业务元数据通过 SourceDecoder 解码到 Output.Sourcetype Searcher[T any] struct {OptionsesESClientsourceDecoder SourceDecoder[T]}// Output 的 Source 字段直接是调用方定义的类型 Ttype Output[T any] struct {IDstringContent stringScore float64RerankScore *float64SourceT// 不是 interface{},是 T}

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

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的类型参数一致,一切类型安全:

type MySource struct {DocIDstring `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方法中,模型调用失败直接返回空标签:

// 分类是辅助能力:模型调用失败不能阻断文档主流程。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方法中,解码失败或重编码失败都返回原图:

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

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

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

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

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

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

这四个模式不是独立存在的。把它们放在一起看:Options 模式让调用方控制所有行为参数,最小接口让调用方自由替换外部依赖,泛型 Searcher[T]让调用方以类型安全的方式拿到业务数据,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 交流真实使用场景。

相关文章

精彩推荐