乐于分享
好东西不私藏

知识库检索:让 Agent 读懂企业内部文档

知识库检索:让 Agent 读懂企业内部文档

系列「企业级 AI Agent 实现拆解」第九篇。上一篇讲了多 LLM Provider,这篇看知识库怎么让 Agent 用上企业内部文档。


为什么不直接把文档塞进 Prompt

LLM 的上下文窗口再大,也塞不下一个企业的文档库。更关键的是经济账:每次调用都把全量文档塞进去,token 消耗会失控——一个 10 万字的规章制度文档,按主流模型的价格算,每次问答花费不菲,没有企业能长期接受。

正确做法是 RAG(Retrieval-Augmented Generation,检索增强生成):用户问问题,先从文档库里检索最相关的片段(topK),只把这几段塞进 prompt,让 LLM 基于这些片段回答。就像你不会把整本词典背下来再回答问题,而是先翻到相关的那几页再看。

DeepFlux 中负责这件事的限界上下文叫 KB(Knowledge Base),下面逐层拆解。


文档入库:四步管道

一篇文档从上传到可检索,经历四个阶段:

原始文档 → [1. 解析] → [2. 切片] → [3. 向量化] → [4. 存储]              Parse      Chunk       Embed         Store

第一步:解析(Parse)

原始文档可能是 Markdown、HTML、纯文本等格式。Parser 负责把各种格式统一转成纯文本:

// infrastructure/parser/parser.gofunc(p *Parser) Parse(_ context.Context, data []byte, mime string) (stringerror) {    switch mime {    case "text/plain""text/markdown""":        return string(data), nil          // 纯文本直接用    case "text/html":        return extractHTML(data)          // HTML 去标签提正文    default:        return "", fmt.Errorf("unsupported mime type %q", mime)    }}

PDF、Word 等复杂格式目前返回错误提示,需要外部管线(如 unstructured.io)预处理。

第二步:切片(Chunking)

一篇 5000 字的文档不能整篇存成一个向量——那样检索精度太差。需要按语义切成小片段。

怎么切很有讲究:太短则上下文丢失,太长则检索精度下降。DeepFlux 默认配置是每片最多 800 个 token,相邻片段有 100 token 的重叠(避免关键信息被切断在边界上),最短不少于 20 个 token(太短的碎片会和相邻片段合并)。

更妙的是,不同类型的文档用不同的切分策略:

策略
适用文档
切法
markdown_heading
Markdown
按标题层级切(## 是天然的分段边界)
semantic_boundary
普通长文
按段落边界(\n\n)切
fixed_window
兜底默认
滑动窗口,固定大小
structured_record
JSON / CSV
每条记录一个片段
page_aware
每页内容少的 PDF
按页切

系统用一个 Registry + Decide 决策树自动选择最合适的策略:

// chunking/strategy.gofunc (r *Registry) Decide(doc *Document) Strategy {    switch {    case doc.Mime == "text/markdown":        return r.strategies["markdown_heading"]    case doc.Mime == "application/pdf":        if avgCharsPerPage(doc) < 200 {            return r.strategies["page_aware"]     // 每页字少 → 按页切        }        return r.strategies["semantic_boundary"]   // 否则按段落切    case isStructuredMime(doc.Mime):        return r.strategies["structured_record"]   // JSON/CSV 按记录切    default:        return r.strategies["fixed_window"]        // 兜底:滑动窗口    }}

你可以把切分策略想象成"切蛋糕":整块蛋糕太大(一篇文档),需要切成小块(片段)。Markdown 天然有标题做"切痕",普通文本要找合理的切分点,结构化数据每行就是一块。

第三步:向量化(Embedding)

每个切片通过 Embedder 接口生成一个 1536 维的浮点数向量。这个向量是该片段的"语义指纹"——语义相近的文本,向量距离也近。

Embedder 是 port 接口,底层可以是 OpenAI 的 text-embedding-3-small、本地部署的 BGE 模型、或其他兼容模型。同一个命名空间(namespace)里的所有片段必须用同一个 embedding model,因为不同模型的向量空间不兼容。

第四步:分层存储

数据
存储位置
用途
原始文档
MinIO(对象存储)
文件归档、重新索引
片段文本 + 元数据
PostgreSQL
关键词搜索、状态管理
向量
Qdrant(向量数据库)
语义相似度检索

PostgreSQL 还可以通过 pgvector 扩展存向量,作为 Qdrant 的备选方案——两个适配器实现同一个 ChunkStore 接口,可以按部署环境选择。


检索:语义匹配 + 多租户隔离

用户提问时,检索流程是:

用户问题 → Embedder 生成向量 → Qdrant 近邻搜索 → 返回 topK 片段

Qdrant 搜索的实际代码如下:

// infrastructure/vector/qdrant.goconst collection = "kb_chunks"   // 所有租户共用一个 collectionfunc(s *QdrantStore) Search(ctx context.Context, tenantID string,    ns model.NamespaceID, vec []float32, topK int, filter map[string]any,) ([]domain.Hit, error) {    // 通过 payload filter 实现租户隔离    must := []map[string]any{        {"key""tenant_id",    "match"map[string]any{"value": tenantID}},        {"key""namespace_id""match"map[string]any{"value"string(ns)}},    }    for k, v := range filter {        must = append(must, map[string]any{"key": k, "match"map[string]any{"value": v}})    }    // POST /collections/kb_chunks/points/search    reqBody := map[string]any{        "vector":       vec,        "limit":        topK,        "with_payload"true,        "filter":       map[string]any{"must": must},    }    // ... 发送 HTTP 请求并解析结果}

多租户隔离通过 payload 过滤实现:每个向量点(point)存储时带上了 tenant_idnamespace_id 等元数据,搜索时必须匹配当前租户 ID。这比"每个租户独立 collection"更灵活——不需要动态创建/删除 collection,也方便跨租户的数据管理。PostgreSQL 层同样有 RLS(行级安全策略)做双重保障。

每个切片存储的 payload 结构:

Payload: map[string]any{    "document_id":  string(c.DocumentID),    "namespace_id"string(c.NamespaceID),    "tenant_id":    c.TenantID,    "chunk_index":  c.Index,    "text":         c.Text,}

Agent 怎么调用知识库

检索到的 topK 片段通过 kb.search 工具返回给 Agent。这个工具定义在 agent BC 的 toollocal 包里,是 agent 本地工具(不走 tool-broker gRPC),危险等级 safe

用户:我们公司的年假政策是什么?Agent 思考:需要查 HR 制度文档Agent 调用 kb.search(query="年假政策")工具返回:{  "results": [    {"title""员工手册""content""入职满1年享有10天年假...""score"0.87}  ]}Agent 基于片段回答用户

kb.search 的设计有个巧妙的分层:它依赖一个 Searcher 接口,而不是直接调 KB BC 的 gRPC:

// agent/infrastructure/toollocal/kb_search.gotype Searcher interface {    Search(ctx context.Context, query string, tokens []string) []ScoredDoc}

目前 Demo 阶段用 InMemorySearcher(内置了几十条演示文档),生产环境换成 GrpcSearcher(通过 gRPC 调 KB BC)——只改一行 wire 代码,tool 的业务逻辑(分数阈值过滤、TopK 截断、输出格式)完全不变。

kb.search 工具  ├── InMemorySearcher   ← Demo 用,数据编入二进制,零外部依赖  └── GrpcSearcher       ← 生产用,调 KB BC 的 KBService.Retrieve RPC

对 Agent 来说,kb.search 就是一个"输入问题、返回相关文段"的黑盒,它不需要知道底层是内存检索还是向量数据库。


跟 Eino 的关系

Eino 定义了 retriever.Retriever 接口,Retrieve(ctx, query) 返回相关文档列表。在 infrastructure/kbclient/grpc.go 里,GrpcSearcher 包装了对 KB BC 的 gRPC 调用,实现了 toollocal.Searcher 接口——它可以直接被 kb.search 工具调用,也可以在未来被包装成 Eino Retriever 节点。

两种使用方式各有场景:

  • ReAct 场景
    :Agent 自主决定什么时候调 kb.search 工具,更灵活
  • 纯 Graph 场景
    :在 Graph 里放一个固定的 Retriever 节点,每次都先检索再推理,更可控

小结

KB 这层设计的重点是三个边界清晰:

  1. 解析、切片、向量化、存储
    全在 KB BC 内部完成,Agent 不感知细节
  2. 多租户隔离
    通过 payload 过滤(Qdrant)+ RLS(PostgreSQL)双重保障,所有租户共用一个 collection
  3. 对 Agent 暴露的接口
    只有 kb.search 工具,参数是自然语言,返回带分数的文段列表
  4. 5 种切分策略
    自动选择,不同文档类型用最合适的切法,提升检索精度

下一篇:长期记忆 —— Agent 怎么"记住"用户