系列「企业级 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) (string, error) {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 | ## 是天然的分段边界) | |
semantic_boundary | \n\n)切 | |
fixed_window | ||
structured_record | ||
page_aware |
系统用一个 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,因为不同模型的向量空间不兼容。
第四步:分层存储
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/searchreqBody := map[string]any{"vector": vec,"limit": topK,"with_payload": true,"filter": map[string]any{"must": must},}// ... 发送 HTTP 请求并解析结果}
多租户隔离通过 payload 过滤实现:每个向量点(point)存储时带上了 tenant_id、namespace_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 这层设计的重点是三个边界清晰:
- 解析、切片、向量化、存储
全在 KB BC 内部完成,Agent 不感知细节 - 多租户隔离
通过 payload 过滤(Qdrant)+ RLS(PostgreSQL)双重保障,所有租户共用一个 collection - 对 Agent 暴露的接口
只有 kb.search工具,参数是自然语言,返回带分数的文段列表 - 5 种切分策略
自动选择,不同文档类型用最合适的切法,提升检索精度
下一篇:长期记忆 —— Agent 怎么"记住"用户
夜雨聆风