LlamaIndex 知识库问答入门

AI Agent 工程实践教程 · 第 04 章

用 LlamaIndex 组织索引、检索和问答链路,并结合学生端代码理解落地结构。

返回系列目录

第四天_LlamaIndex知识库问答入门

第四天:LlamaIndex 知识库问答入门

前三天我们已经知道:

  • 大模型本身不一定知道我们的业务资料。
  • 想让大模型回答业务问题,需要把外部资料检索出来,再交给大模型。
  • 这个流程通常叫 RAG。

第四天要解决的问题是:

真实项目里,RAG 不应该每一步都手写。

我们要学习 LlamaIndex,用它把“读取资料、切分资料、生成向量、写入向量库、检索资料、组织 Prompt、调用大模型”这些流程组织起来。

这一节课学完以后,学生要能说清楚:

  • LlamaIndex 是干什么的。
  • Document、Node、VectorStore、StorageContext、VectorStoreIndex 分别是什么。
  • LlamaIndex 怎么接入 Milvus。
  • VectorStoreIndex(...) 这行代码到底做了什么。
  • 在燕雀管理平台里,前端、Java、Python、Milvus 各自负责什么。

1. 为什么学完 RAG 还要学 LlamaIndex

上一节我们已经手写过一个简单 RAG。

流程大概是这样:

为什么学完 RAG 还要学 LlamaIndex

手写 RAG 的好处是容易理解原理:

  1. 准备资料。
  2. 把资料切成小段。
  3. 把每个小段转成向量。
  4. 把向量保存到 Milvus。
  5. 用户提问时,再把问题转成向量。
  6. 去 Milvus 里查相似内容。
  7. 把查到的内容和问题一起交给大模型。
  8. 大模型生成回答。

但是到了真实项目,这样写会越来越乱。

比如燕雀管理平台要做知识库管理:

  • 一个平台里会有多个知识库。
  • 每个知识库里会有很多文档。
  • 文档要上传、入库、重新入库、删除。
  • 每个文档要切成多个 chunk。
  • 每个 chunk 要保存原始文本、文档 ID、知识库 ID、版本号、来源地址。
  • 查询时要先检索,再把资料组织进 Prompt。

如果这些都自己手写,代码很快就会变成一堆流程代码。

所以我们需要一个框架来帮我们组织 RAG。

这个框架就是 LlamaIndex。


2. LlamaIndex 是什么

一句话:

LlamaIndex 是一个用来搭建知识库问答和 RAG 应用的框架。

它不是大模型。

它也不是向量数据库。

它更像是一个“资料到大模型之间的组织层”。

大模型负责生成回答。

Milvus 负责保存向量和相似度检索。

LlamaIndex 负责把中间流程串起来:

业务资料
  ↓
读取资料
  ↓
切分资料
  ↓
生成向量
  ↓
写入向量库
  ↓
根据问题检索相关资料
  ↓
组织 Prompt
  ↓
调用大模型生成回答

可以把 LlamaIndex 理解成:

LlamaIndex = 帮我们组织 RAG 流程的框架

它解决的不是“向量怎么存”,而是:

一份业务资料从上传到被大模型使用,中间应该怎么被组织。


3. LlamaIndex 里的核心对象

先把几个核心对象讲清楚,后面看代码就不会乱。

LlamaIndex 核心概念

3.1 Document:一整份资料

Document 表示一份原始资料。

比如:

  • 一篇 Markdown 文档。
  • 一个 PDF 文件。
  • 一段从数据库查出来的说明。
  • 一篇产品介绍。

在代码里可以这样创建:

from llama_index.core import Document

document = Document(
    text="这里是一整份 Markdown 文档的内容",
    id_="doc_001",
    metadata={
        "knowledge_base_id": "kb_001",
        "document_id": "doc_001",
        "document_name": "课程介绍.md",
    },
)

这里要注意:

  • text 是文档正文。
  • id_ 是 LlamaIndex 里这份文档的 ID。
  • metadata 是业务信息,比如知识库 ID、文档 ID、文件名、版本号。

metadata 很重要。

因为向量库查回来的不是“一个孤立的向量”,而是“某个文档里的某个片段”。

如果没有 metadata,我们就不知道这个片段来自哪个知识库、哪份文档。

3.2 Node:切分后的资料片段

一份文档通常很长,不能整篇都塞给大模型。

所以 LlamaIndex 会把 Document 切成多个 Node

可以这样理解:

Document:一整篇文档
Node:文档切开之后的一小段

例如:

课程介绍.md
  ↓
Node 0:课程目标...
Node 1:课程安排...
Node 2:项目实战...

在 RAG 里,真正被向量化、被检索的通常是 Node。

因为用户提问时,我们要找的是最相关的几个片段,而不是整篇文档。

源码里能看到很多种 Node。

大概关系可以这样理解:

BaseNode
  ├─ Node
  │   └─ Document
  │       └─ ImageDocument
  ├─ TextNode
  │   ├─ ImageNode
  │   └─ IndexNode

常见类型:

类型 通俗理解 什么时候会见到
BaseNode 所有 Node 的基础抽象 看源码、类型标注时会看到
Document 一整份原始文档 刚读取 Markdown、PDF、网页时
TextNode 一段可检索的文本片段 文档切分以后最常见
ImageNode 带图片信息的节点 多模态、图片检索场景
ImageDocument 图片文档 把图片作为一份文档处理时
IndexNode 可以指向另一个索引或对象的节点 高级索引、组合索引场景

这节课主要关注两个:

Document:入库前的一整份 Markdown
TextNode:切分后真正写入向量库的文本片段

平时我们口头说的 Node,大多数时候就是在说这些被切出来、准备向量化和检索的文本片段。

3.3 NodeParser:把 Document 切成 Node

LlamaIndex 里负责“把 Document 切成 Node”的组件,通常叫 NodeParser

可以把它理解成:

NodeParser = 文档切分器的统一抽象

它下面有很多具体实现。

大概的继承关系可以这样看:

NodeParser
  ├─ TextSplitter
  │   ├─ MetadataAwareTextSplitter
  │   │   ├─ SentenceSplitter
  │   │   └─ TokenTextSplitter
  │   ├─ CodeSplitter
  │   └─ LangchainNodeParser
  ├─ MarkdownNodeParser
  ├─ HTMLNodeParser
  ├─ JSONNodeParser
  ├─ SentenceWindowNodeParser
  ├─ SemanticSplitterNodeParser
  └─ HierarchicalNodeParser

也就是说:

  • NodeParser 是最上层的统一抽象。
  • TextSplitter 是专门处理文本切分的一类 NodeParser
  • SentenceSplitterTokenTextSplitterCodeSplitter 是更具体的切分方式。
  • MarkdownNodeParserHTMLNodeParserJSONNodeParser 这类更偏“按文件结构解析”。

常见的有:

适合处理什么
SentenceSplitter 普通文本、Markdown 正文,按句子和段落尽量自然切分
TokenTextSplitter 按 token 数量控制长度,适合严格控制上下文大小
CodeSplitter 代码文件,尽量按代码结构切分
MarkdownNodeParser Markdown 文档,按 Markdown 标题结构切分
HTMLNodeParser HTML 页面
JSONNodeParser JSON 数据
SentenceWindowNodeParser 保留句子前后窗口,适合需要上下文的检索
SemanticSplitterNodeParser 按语义相似度切分,效果更智能,但成本更高

所以 SentenceSplitter 只是其中一种。

它不是唯一的切分器。

不过我们现在的知识库只支持 Markdown。

所以更合理的策略是:

先用 MarkdownNodeParser 按 Markdown 标题结构切
  ↓
如果某个 Node 还是太长
  ↓
再用 SentenceSplitter 二次切分

这样做有两个好处:

  • 能保留 Markdown 的标题结构,比如一级标题、二级标题。
  • 又能避免某个标题下面内容太长,导致单个 Node 太大。

代码可以这样写:

from llama_index.core import Document
from llama_index.core.node_parser import MarkdownNodeParser, SentenceSplitter


markdown_parser = MarkdownNodeParser()

sentence_splitter = SentenceSplitter(
    chunk_size=800,
    chunk_overlap=120,
)


def split_markdown_document(document: Document):
    markdown_nodes = markdown_parser.get_nodes_from_documents([document])

    final_nodes = []
    for node in markdown_nodes:
        content = node.get_content(metadata_mode="none")

        if len(content) <= 800:
            final_nodes.append(node)
            continue

        temp_document = Document(
            text=content,
            id_=node.id_,
            metadata=dict(node.metadata or {}),
        )
        final_nodes.extend(
            sentence_splitter.get_nodes_from_documents([temp_document])
        )

    return final_nodes


nodes = split_markdown_document(document)

这里面两个 parser 的分工很清楚:

组件 作用
MarkdownNodeParser 先按 Markdown 标题结构切分
SentenceSplitter 只处理太长的 Node,把长段落继续切小

SentenceSplitter 的两个常见参数:

参数 含义
chunk_size 每个片段大概多长
chunk_overlap 相邻片段之间重叠多少内容

为什么需要重叠?

因为一句话的上下文可能跨越两个片段。

如果完全不重叠,检索出来的内容可能缺前半句或者缺后半句。

3.4 VectorStore:向量存储抽象

VectorStore 是 LlamaIndex 对“向量数据库”的抽象。

它不一定是 Milvus,也可以是别的向量库。

它负责的事情很明确:

保存向量
保存文本片段
保存 metadata
根据问题向量查相似片段

所以 VectorStore 是一个接口概念。

Milvus 是其中一种具体实现。

3.5 MilvusVectorStore:连接 Milvus 的适配器

MilvusVectorStore 是 LlamaIndex 提供的 Milvus 适配器。

也就是说,LlamaIndex 通过它来操作 Milvus。

from llama_index.vector_stores.milvus import MilvusVectorStore

vector_store = MilvusVectorStore(
    uri="http://localhost:19530",
    token="your_milvus_token",
    collection_name="yanque_knowledge",
    dim=1024,
)

这段代码的意思是:

LlamaIndex
  ↓
MilvusVectorStore
  ↓
Milvus

常见参数:

参数 作用
uri Milvus 服务地址
token Milvus 认证信息
collection_name 要写入的 Milvus Collection
dim 向量维度,必须和 Embedding 模型输出维度一致
overwrite 是否覆盖已有 Collection,生产环境一般不要随便开

默认情况下,MilvusVectorStore 会建这些基础字段:

id
doc_id
text
embedding

这些字段也可以部分自定义。

比如我们项目里希望 Milvus 字段更像业务系统,就可以这样配置:

vector_store = MilvusVectorStore(
    uri=settings.milvus_uri,
    token=settings.milvus_token or "",
    collection_name=collection_name,
    dim=vector_dim,
    doc_id_field="document_id",
    text_key="text",
    embedding_field="vector",
)

这样新建出来的 Collection 会更接近:

id
document_id
text
vector

这里有一个坑:

text_key 理论上能传参数,但当前 LlamaIndex 写入时会从 TextNode 里取 node.dict()[text_key]

TextNode 的正文键默认就是 text

所以项目里先不要把 text_key 改成 content

如果真的想把正文列叫 content,就要自己封装一层 VectorStore 或改写写入逻辑。

MilvusVectorStore 自己也提供了一些常见方法。

这些方法大多数情况下不需要我们直接频繁调用,因为 VectorStoreIndexRetriever 会帮我们间接调用。

但是做真实项目时,至少要知道它们的含义。

方法 作用 常见场景
add(nodes) 把已经带有 embedding 的 Node 写入 Milvus VectorStoreIndex 入库时底层会调用
query(query) 根据查询向量去 Milvus 检索相似 Node Retriever 检索时底层会调用
delete(ref_doc_id=...) 按文档 ID 删除这份文档对应的 Node 文档重新入库前,先删旧 chunk
delete_nodes(node_ids=...) 按 Node ID 删除指定 Node 精确删除某几个 chunk
get_nodes(...) 根据 Node ID 或 metadata 过滤条件取回 Node 调试、排查、后台管理
clear() 删除整个 Collection 测试环境清空数据,生产环境慎用
async_add(...) / adelete(...) 异步版本方法 异步服务中使用

比如我们项目里重新入库一份文档时,会先删旧 chunk:

vector_store.delete(
    ref_doc_id=request.document_id
)

这里的 ref_doc_id 可以理解成:

这批 Node 属于哪一份原始文档

它不是随便去我们自己写的 metadata["document_id"] 里匹配。

LlamaIndex 在把 Node 写入向量库时,会根据 node.ref_doc_id 自动生成一些字段:

doc_id
document_id
ref_doc_id

MilvusVectorStore.delete(ref_doc_id=...) 默认匹配的是其中的 doc_id 字段。

所以如果希望下面这行代码能删掉整份文档的旧 chunk:

vector_store.delete(ref_doc_id=request.document_id)

就要保证这些 Node 的 ref_doc_id 都指向同一个原始文档 ID。

在我们的项目里,原始 Document 是这样创建的:

Document(
    text_resource=MediaResource(text=markdown),
    id_=request.document_id,
    metadata={...},
)

后面无论用 MarkdownNodeParser 切,还是长段落再用 SentenceSplitter 二次切,都要保留这个原始 document_id

删除旧 chunk 以后,再用 VectorStoreIndex(nodes, ...) 写入新 chunk。

这样同一份文档重新上传后,Milvus 里不会同时存在旧版本和新版本的内容。

学生最容易误会的一点是:

MilvusVectorStore 不是向量,它是连接 Milvus 的工具。

3.6 StorageContext:告诉 LlamaIndex 数据存在哪里

StorageContext 可以理解成 LlamaIndex 的“存储配置对象”。

它告诉 LlamaIndex:

你生成出来的向量,不要存在默认地方,要存到这个 vector_store 里。

代码:

from llama_index.core import StorageContext

storage_context = StorageContext.from_defaults(
    vector_store=vector_store
)

这里的 vector_store 就是上面的 MilvusVectorStore

所以这段代码的意思是:

LlamaIndex 的存储上下文
  ↓
使用 MilvusVectorStore
  ↓
最终写入 Milvus

3.7 VectorStoreIndex:组织入库和查询的核心对象

VectorStoreIndex 是这节课最重要的对象。

它不是一个向量。

它也不是 Milvus 里的索引。

它是 LlamaIndex 里用来组织“向量化、入库、检索”的结构。

LlamaIndex Index 是什么

常见写法:

from llama_index.core import VectorStoreIndex

index = VectorStoreIndex(
    nodes,
    storage_context=storage_context,
    embed_model=embed_model,
)

这行代码会做几件事:

  1. 遍历每个 Node。
  2. embed_model 把 Node 文本转成向量。
  3. 把向量、文本、metadata 交给 storage_context
  4. storage_context 再交给 MilvusVectorStore
  5. 最终写入 Milvus。

所以:

VectorStoreIndex(nodes, ...)

不是只创建了一个 Python 对象。

它会触发向量化和入库。

如果文档已经入过库,只想基于已有 Milvus Collection 查询,一般会用:

index = VectorStoreIndex.from_vector_store(
    vector_store=vector_store,
    embed_model=embed_model,
)

两种写法的区别:

写法 作用
VectorStoreIndex(nodes, ...) 有新资料,要向量化并写入向量库
VectorStoreIndex.from_vector_store(...) 资料已经在向量库里,只构建查询入口

3.8 Retriever:只负责检索

Retriever 只做一件事:

根据用户问题,从向量库里找相似资料。

retriever = index.as_retriever(similarity_top_k=3)
nodes = retriever.retrieve("LlamaIndex 是什么?")

它返回的是相关的 Node。

它不会调用大模型生成最终答案。

也就是说,Retriever 的职责更像是:

用户问题
  -> 生成问题向量
  -> 去向量库里做相似度搜索
  -> 返回最相关的几个 Node

这里的 similarity_top_k=3 表示:

最多取回 3 个最相似的片段

如果写成项目里的形式:

retriever = index.as_retriever(
    similarity_top_k=request.limit,
)

意思就是:

前端或接口传入 limit
后端用 request.limit 控制最多返回几个相关片段

比如:

request.limit = 3

就代表最多取回 3 个 chunk。

这个参数会影响检索结果:

参数值 效果
similarity_top_k=1 只取最相似的 1 个片段,结果少,速度快,但可能漏信息
similarity_top_k=3 常见设置,能取回几个相关片段,适合普通问答
similarity_top_k=10 取回更多片段,召回更充分,但后续给大模型的上下文会更多

不过 as_retriever(...) 里面不是只能传 similarity_top_k 一个参数。

similarity_top_k 只是最常用的参数。

后面项目复杂以后,还可以加入 metadata 过滤。

比如只检索某一个知识库里的内容:

from llama_index.core.vector_stores import MetadataFilter, MetadataFilters

retriever = index.as_retriever(
    similarity_top_k=request.limit,
    filters=MetadataFilters(
        filters=[
            MetadataFilter(
                key="knowledge_base_id",
                value=request.knowledge_base_id,
            )
        ]
    ),
)

也可以只检索某一篇文档:

from llama_index.core.vector_stores import MetadataFilter, MetadataFilters

retriever = index.as_retriever(
    similarity_top_k=request.limit,
    filters=MetadataFilters(
        filters=[
            MetadataFilter(
                key="document_id",
                value=request.document_id,
            )
        ]
    ),
)

所以可以这样理解:

similarity_top_k:控制取回多少个相似片段
filters:控制从哪些数据里检索

在真实项目里,通常不会直接从整个向量库里乱查。

更常见的做法是:

先用 metadata 限定范围
再用 similarity_top_k 控制返回数量

比如在我们的知识库项目里,查询时至少应该考虑这些条件:

knowledge_base_id:查哪个知识库
document_id:是否只查某篇文档
limit:最多返回几个 chunk

最后再强调一次:

Retriever 只负责把资料找出来。

至于后面怎么组装 Prompt、怎么调用大模型、怎么生成最终答案,那是下一层问答流程要做的事情。


4. 不要把三个 Index 混在一起

这里一定要讲清楚。

学生很容易把“向量”“LlamaIndex 的 Index”“Milvus 的索引”混成一个东西。

它们不是一回事。

名称 是什么 举例
向量 一段文本经过 Embedding 模型得到的一串数字 [0.12, -0.08, ...]
LlamaIndex 的 VectorStoreIndex LlamaIndex 用来组织入库和查询的结构 VectorStoreIndex(nodes, ...)
Milvus 的索引 Milvus 为了加速向量检索建立的底层检索结构 HNSWIVF_FLAT

更通俗一点:

向量:文本变成的数字
VectorStoreIndex:LlamaIndex 的流程组织者
Milvus 索引:Milvus 内部为了查得更快建的结构

VectorStoreIndex 不是 Milvus 的索引。

它的重点不是“数据库底层怎么加速”,而是“资料如何被 LlamaIndex 组织成可检索的数据”。


5. LlamaIndex 怎么接入 Milvus

LlamaIndex 可以直接接入 Milvus。

用的就是:

MilvusVectorStore

整体关系如下:

LlamaIndex 接入 Milvus

入库流程:

Markdown 文档
  ↓
Document
  ↓
Node
  ↓
Embedding 模型生成向量
  ↓
MilvusVectorStore
  ↓
Milvus Collection

查询流程:

用户问题
  ↓
Embedding 模型生成问题向量
  ↓
MilvusVectorStore 去 Milvus 检索
  ↓
返回相关 Node
  ↓
QueryEngine 组织 Prompt
  ↓
大模型生成回答

5.1 为什么不直接用 Milvus

Milvus 本身很强,但它主要负责向量数据库能力。

为什么不只用 Milvus

Milvus 擅长做:

  • 保存向量。
  • 保存字段。
  • 根据向量相似度查询。
  • 返回相似数据。

但是一个完整知识库问答系统还需要:

  • 读取文档。
  • 切分文档。
  • 生成向量。
  • 保存 metadata。
  • 查询时拿回相关 Node。
  • 把 Node 组织进 Prompt。
  • 调用大模型生成回答。

这些上层流程就是 LlamaIndex 要组织的。

所以可以这样理解:

Milvus 是向量数据库
LlamaIndex 是 RAG 流程框架
MilvusVectorStore 是两者之间的连接器

5.2 Collection 怎么设计

在课程 Demo 里,可以先用一个固定 Collection:

yanque_knowledge

这样最容易理解。

在真实燕雀管理平台里,更推荐:

一个知识库一个 Collection

例如:

knowledge_base_id = kb_001
collection_name = yanque_knowledge_kb_001

这样做的好处是:

  • 不同知识库之间天然隔离。
  • 删除一个知识库时更简单。
  • 查询某个知识库时不用再额外过滤 knowledge_base_id

但是也要注意:

  • Collection 太多会增加 Milvus 管理成本。
  • 向量维度一旦创建,后面不能随便改。
  • 同一个 Collection 里必须使用同一种 Embedding 维度。

5.3 metadata 里应该放什么

向量库里不能只存向量。

还要存这个向量对应的业务信息。

常见 metadata:

metadata = {
    "knowledge_base_id": "kb_001",
    "document_id": "doc_001",
    "document_name": "LlamaIndex入门.md",
    "chunk_index": 0,
    "version": "v1",
    "source": "tos-presigned-url-or-object-key",
}

这些字段的作用是:

字段 作用
knowledge_base_id 知道属于哪个知识库
document_id 知道来自哪份文档
document_name 前端展示来源
chunk_index 知道是文档里的第几个片段
version 重新入库时区分版本
source 追踪原始文件来源

在 LlamaIndex 里推荐传 metadata

旧版本里也能看到 extra_info,但它已经是兼容旧版本的写法。

现在直接写 metadata 更清楚。


6. 第一个 LlamaIndex + Milvus Demo

这一节先跑通最小 Demo。

目标:

读取 Markdown
  ↓
写入 Milvus
  ↓
提问
  ↓
返回基于资料的回答

6.1 安装依赖

uv add llama-index llama-index-embeddings-openai llama-index-vector-stores-milvus pymilvus

如果不是 uv 项目,也可以用:

pip install llama-index llama-index-embeddings-openai llama-index-vector-stores-milvus pymilvus

6.2 准备 .env

示例配置:

BAILIAN_API_KEY=你的百炼或者兼容 OpenAI 的 API Key
BAILIAN_BASE_URL=your_placeholder
BAILIAN_LLM_MODEL=your_placeholder
BAILIAN_EMBEDDING_MODEL=your_placeholder
BAILIAN_EMBEDDING_BATCH_SIZE=10

MILVUS_URI=http://localhost:19530
MILVUS_TOKEN=your_milvus_token
MILVUS_COLLECTION=yanque_knowledge
KNOWLEDGE_VECTOR_DIM=1024

这里的 KNOWLEDGE_VECTOR_DIM=1024 要和 Embedding 模型输出维度一致。

如果换了 Embedding 模型,维度也可能要跟着换。

6.3 准备资料目录

创建目录:

data/
  llamaindex_intro.md

示例内容:

# LlamaIndex 简介

LlamaIndex 是一个用于构建 RAG 应用的框架。
它可以帮助开发者读取资料、切分资料、生成向量、连接向量数据库,并基于资料进行问答。

6.4 编写代码

新建 demo_llamaindex_milvus.py

import os

from dotenv import load_dotenv
from llama_index.core import Document, Settings, SimpleDirectoryReader, StorageContext, VectorStoreIndex
from llama_index.core.node_parser import MarkdownNodeParser, SentenceSplitter
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.llms.openai import OpenAI
from llama_index.vector_stores.milvus import MilvusVectorStore


load_dotenv()


# 1. 配置大模型。
# QueryEngine 最后生成答案时,会使用这个模型。
Settings.llm = OpenAI(
    model=os.getenv("BAILIAN_LLM_MODEL", "qwen-plus"),
    api_key=your_placeholder"BAILIAN_API_KEY"),
    api_base=your_placeholder"BAILIAN_BASE_URL"),
)


# 2. 配置 Embedding 模型。
# 文档入库时,Node 会用它转成向量。
# 用户提问时,问题也会用它转成向量。
embed_model = OpenAIEmbedding(
    # 百炼的 text-embedding-v4 不是 OpenAIEmbedding 内置枚举值。
    # 用 model_name 可以把模型名原样传给 OpenAI compatible 接口。
    model_name=os.getenv("BAILIAN_EMBEDDING_MODEL", "text-embedding-v4"),
    # 百炼 Embedding 接口限制单次最多 20 条文本。
    # 这里设置成 10,避免 LlamaIndex 默认 batch size 过大。
    embed_batch_size=int(os.getenv("BAILIAN_EMBEDDING_BATCH_SIZE", "10")),
    api_key=your_placeholder"BAILIAN_API_KEY"),
    api_base=your_placeholder"BAILIAN_BASE_URL"),
)

Settings.embed_model = embed_model


# 3. 配置 Markdown 切分器。
# 当前知识库只支持 md,所以先用 MarkdownNodeParser 按标题结构切。
markdown_parser = MarkdownNodeParser()


# 4. 配置长段落兜底切分器。
# 如果某个 Markdown 标题下面内容太长,再用 SentenceSplitter 二次切小。
sentence_splitter = SentenceSplitter(
    chunk_size=800,
    chunk_overlap=120,
)


def split_markdown_documents(documents: list[Document]):
    nodes = []

    for document in documents:
        # 第一步:按 Markdown 标题结构切分。
        markdown_nodes = markdown_parser.get_nodes_from_documents([document])

        for node in markdown_nodes:
            content = node.get_content(metadata_mode="none")

            # 第二步:短 Node 直接保留。
            if len(content) <= 800:
                nodes.append(node)
                continue

            # 第三步:长 Node 再交给 SentenceSplitter 二次切分。
            # 这里把长 Node 重新包装成临时 Document,是为了继续复用 LlamaIndex 的切分入口。
            temp_document = Document(
                text=content,
                id_=node.id_,
                metadata=dict(node.metadata or {}),
            )
            nodes.extend(
                sentence_splitter.get_nodes_from_documents([temp_document])
            )

    return nodes


# 5. 创建 MilvusVectorStore。
# 它不是向量,也不是 Milvus 的底层索引。
# 它是 LlamaIndex 连接 Milvus 的适配器。
vector_store = MilvusVectorStore(
    uri=os.getenv("MILVUS_URI", "http://localhost:19530"),
    token=os.getenv("MILVUS_TOKEN", "your_milvus_token"),
    collection_name=os.getenv("MILVUS_COLLECTION", "yanque_knowledge"),
    dim=int(os.getenv("KNOWLEDGE_VECTOR_DIM", "1024")),
    overwrite=False,
)


# 6. 创建 StorageContext。
# 这一步是在告诉 LlamaIndex:
# 后面生成的向量,要通过 vector_store 写入 Milvus。
storage_context = StorageContext.from_defaults(
    vector_store=vector_store
)


# 7. 读取 data 目录下的 Markdown 文档。
# SimpleDirectoryReader 会把文件读成 LlamaIndex 的 Document。
documents = SimpleDirectoryReader(
    input_dir="data",
    required_exts=[".md"],
).load_data()


# 8. 把 Document 切成 Node。
# 这里使用“MarkdownNodeParser + SentenceSplitter”的组合策略:
# 先按 Markdown 标题切,如果某一段太长,再按句子继续切。
nodes = split_markdown_documents(documents)


# 9. 构建 VectorStoreIndex。
# 这一步会触发:
# Node -> Embedding -> MilvusVectorStore -> Milvus
index = VectorStoreIndex(
    nodes,
    storage_context=storage_context,
    embed_model=embed_model,
)


# 10. 创建 QueryEngine。
# QueryEngine 会先检索相关 Node,再调用大模型生成答案。
query_engine = index.as_query_engine(
    similarity_top_k=3
)


answer = query_engine.query("LlamaIndex 是干什么的?")
print(answer)

6.5 运行

uv run python demo_llamaindex_milvus.py

如果运行成功,说明:

  • Markdown 被读成了 Document。
  • Document 被切成了 Node。
  • Node 被向量化。
  • 向量和文本被写入了 Milvus。
  • 问题被转换成向量。
  • Milvus 返回了相似 Node。
  • 大模型基于 Node 生成了回答。

7. 代码背后到底发生了什么

最后把代码拆开讲一遍。

这一章的目标不是再写新代码,而是让学生真正理解每一行在干什么。

7.1 SimpleDirectoryReader:读取资料

documents = SimpleDirectoryReader(
    input_dir="data",
    required_exts=[".md"],
).load_data()

这一步会把 Markdown 文件读成 Document

可以理解成:

data/llamaindex_intro.md
  ↓
Document(text=文件内容, metadata=文件信息)

7.2 MilvusVectorStore:指定向量存到 Milvus

vector_store = MilvusVectorStore(
    uri="http://localhost:19530",
    token="your_milvus_token",
    collection_name="yanque_knowledge",
    dim=1024,
)

这一步只是创建连接 Milvus 的适配器。

它不会自己读取文档,也不会自己生成答案。

它主要负责:

  • 写入向量。
  • 写入文本。
  • 写入 metadata。
  • 查询相似向量。

7.3 StorageContext:把存储位置交给 LlamaIndex

storage_context = StorageContext.from_defaults(
    vector_store=vector_store
)

这一步的意思是:

后面 LlamaIndex 生成的向量,都交给这个 vector_store 保存。

如果没有这一步,LlamaIndex 不知道我们要把向量写到 Milvus。

7.4 先切成 Node,再交给 VectorStoreIndex

nodes = split_markdown_documents(documents)

index = VectorStoreIndex(
    nodes,
    storage_context=storage_context,
    embed_model=embed_model,
)

这是入库的核心。

这里分成了两步。

第一步,我们自己控制切分策略:

Document
  ↓
MarkdownNodeParser 按标题结构切
  ↓
如果某个 Node 太长
  ↓
SentenceSplitter 二次切分
  ↓
得到最终 nodes

第二步,把最终的 nodes 交给 VectorStoreIndex

nodes
  ↓
每个 Node 文本交给 Embedding 模型
  ↓
得到向量
  ↓
向量 + 文本 + metadata
  ↓
MilvusVectorStore
  ↓
Milvus

所以这行代码:

VectorStoreIndex(nodes, storage_context=storage_context, embed_model=embed_model)

不会再负责“怎么切文档”。

因为文档已经被我们切成了 Node。

它负责的是:

  • 遍历 Node。
  • 调用 Embedding 模型生成向量。
  • 通过 StorageContext 找到 MilvusVectorStore。
  • 把向量、文本、metadata 写入 Milvus。

7.5 as_query_engine:把检索和回答串起来

query_engine = index.as_query_engine(
    similarity_top_k=3
)

这一步会创建一个问答入口。

similarity_top_k=3 表示:

每次提问,先从 Milvus 找最相关的 3 个片段。

然后 QueryEngine 会把这 3 个片段组织进 Prompt,再交给大模型回答。

7.6 燕雀管理平台里的真实流程

课程 Demo 是最小版本。

真实燕雀管理平台里,流程会更完整:

flowchart LR
    A["前端上传 Markdown"] --> B["火山引擎 TOS"]
    A --> C["Java 保存知识库和文档记录"]
    C --> D["Java 生成预签名 fileUrl"]
    D --> E["Python 下载 Markdown"]
    E --> F["LlamaIndex 读取并切分文档"]
    F --> G["Embedding 模型生成向量"]
    G --> H["MilvusVectorStore"]
    H --> I["Milvus Collection"]

各层分工:

负责什么
前端 上传 Markdown,展示知识库和文档状态
Java 保存知识库、文档记录,生成 TOS 预签名地址,调用 Python
Python 下载 Markdown,使用 LlamaIndex 切分、向量化、入库
Milvus 保存向量、文本和 metadata,提供相似度检索
大模型 根据检索到的资料生成最终回答

这个流程里,Python 不需要知道 TOS 的 AK/SK。

Java 生成一个短时间有效的预签名 URL 给 Python。

Python 只需要在入库这一刻下载 Markdown 内容,然后交给 LlamaIndex 处理。

7.7 本节课最后要记住的结论

这一节最重要的不是背 API,而是建立正确理解。

Document 是一整份资料
Node 是切分后的资料片段
MilvusVectorStore 是 LlamaIndex 连接 Milvus 的适配器
StorageContext 告诉 LlamaIndex 向量存在哪里
VectorStoreIndex 组织向量化、入库和检索
Retriever 只负责检索
QueryEngine 负责检索 + 调用大模型回答

再简单一点:

Milvus 负责存和查向量
LlamaIndex 负责组织 RAG 流程

这就是第四天课程的主线。

第四天_学生端AI知识库问答代码讲解

第四天补充:学生端 AI 知识库问答代码讲解

前面已经讲完了 LlamaIndex、Milvus、Document、Node、VectorStoreIndex 和 Retriever。

这一篇不再重复概念,而是回到我们自己的项目代码,看这套学生端 AI 知识库问答是怎么设计出来的。

这节课重点看四件事:

  1. Java 和 Python 为什么这样分工。
  2. LlamaIndex 在代码里到底出现在哪里。
  3. 一次知识库入库和一次学生提问分别怎么流转。
  4. 大模型调用为什么要抽象成基类,后面怎么扩展别的模型。

前端部分只简单说明流式事件的接收方式,页面样式和交互细节留到前端课程里讲。

1. 项目总体架构

这套问答功能不只是“前端调用大模型接口”。

它实际分成了四层:

学生端 AI 问答总体架构

从职责上看:

Java 后端负责业务系统。

Python 服务负责 AI 能力。

Milvus 负责向量存储和相似度检索。

大模型负责根据 Prompt 生成回答。

为什么前端不直接调用 Python?

因为学生登录、签名鉴权、会话归属、消息落库都在 Java 体系里。Python 服务在这里作为内部 AI 能力,由 Java 后端统一调用。

为什么 RAG 和 LlamaIndex 放在 Python 侧?

因为 LlamaIndex、Embedding、模型 SDK、向量库生态主要在 Python 侧更成熟。Java 继续做业务编排,Python 专注 AI 能力,边界会更清楚。

2. 业务表结构

这套功能里有两类数据:

一类存在 MySQL 里,负责业务管理。

一类存在 Milvus 里,负责向量检索。

MySQL 里主要有四张表:

知识库和问答表结构关系

知识库表:knowledge_base

这张表保存“一个知识库”的主信息。

建表 SQL 在:

YanQue-Admin/src/main/resources/sql/knowledge_base.sql

主要字段:

字段 作用
id 知识库 ID
knowledge_base_name 知识库名称
description 知识库描述
status 状态,ACTIVEINACTIVE
created_at 创建时间
updated_at 更新时间

这里有一个唯一索引:

unique key uk_knowledge_base_name (knowledge_base_name)

也就是说,知识库名称不能重复。

知识库文档表:knowledge_document

这张表保存“知识库下面的一份文档”。

主要字段:

字段 作用
id 文档 ID
knowledge_base_id 所属知识库 ID
document_name 文档名称
object_key TOS 对象存储 Key
file_size 文件大小
version 文档版本号
index_status 入库状态:PENDINGINDEXINGINDEXEDFAILED
chunk_count Python 入库后返回的切片数量
vector_dim 向量维度
error_message 入库失败原因
indexed_at 入库完成时间
created_at 创建时间
updated_at 更新时间

这张表只保存文档业务信息,不保存每个 chunk 的正文和向量。

chunk 正文、向量、metadata 会写入 Milvus。

也就是说:

MySQL 记录“有哪些知识库、有哪些文档、文档是否已经入库成功”。

Milvus 知道“每个文档切成了哪些向量片段,以及这些片段如何相似检索”。

问答会话表:ai_chat_session

这张表保存学生的一次 AI 问答会话。

建表 SQL 在:

YanQue-Admin/src/main/resources/sql/ai_chat.sql

主要字段:

字段 作用
id 会话 ID
student_id 学生 ID
title 会话标题
status 状态:ACTIVE 正常,DELETED 删除
created_at 创建时间
updated_at 更新时间

这里有一个索引:

key idx_student_status_updated (student_id, status, updated_at)

它服务于学生端“查询我的会话列表”。

问答消息表:ai_chat_message

这张表保存会话里的每一条消息。

主要字段:

字段 作用
id 消息 ID
session_id 所属会话 ID
role 消息角色:userassistant
content 消息内容
model AI 回复使用的模型名称
tokens 本次 AI 回复消耗的 token 数
created_at 创建时间

用户消息和 AI 消息都存在这张表。

区别是:

用户消息的 roleuser,一般没有 modeltokens

AI 消息的 roleassistant,会保存模型名称和 token 消耗。

MySQL 和 Milvus 分别存什么

这里需要区分两类数据。

MySQL 保存业务数据。

Milvus 保存向量数据。

knowledge_document.chunk_count 只记录这份文档切出了多少个片段,chunk 正文和向量本身不放在 MySQL。

真正参与语义检索的是 Milvus 里的向量记录。

所以问答时大概是:

Java 从 MySQL 查会话和历史消息。

Python 从 Milvus 查知识库 chunk。

大模型根据历史消息和知识库 chunk 生成回答。

3. Python 项目分层

先看 Python 服务目录:

YanQue-AI/src/yanque_ai
├── api
│   ├── chat_api.py
│   └── knowledge_api.py
├── chat
│   ├── service.py
│   ├── rag_service.py
│   ├── knowledge_router.py
│   └── prompt_builder.py
├── knowledge
│   ├── service.py
│   ├── document_loader.py
│   ├── chunker.py
│   └── vector_store.py
├── providers
│   └── llm_provider.py
├── schemas
│   ├── chat.py
│   └── knowledge.py
└── core
    └── config.py

对应关系可以看这张图:

Python 服务分层

这套结构的重点不是增加文件数量,而是让每一层职责更清楚:

api 只接 HTTP 请求。

chat 负责一次问答怎么组织。

knowledge 负责知识库入库、切分、向量化和检索。

providers 负责对接具体大模型。

schemas 负责请求、响应和消息对象。

core/config.py 负责统一读取环境变量。

如果把所有逻辑都写进 chat_api.py,代码也可以运行,但后面扩展模型、替换向量库、调整 RAG 流程都会变得不清晰。现在拆成多个模块,是为了让大家更容易看懂项目结构。

4. 知识库入库入口

知识库入库接口在:

YanQue-AI/src/yanque_ai/api/knowledge_api.py

核心代码:

@router.post("/documents/index", response_model=KnowledgeDocumentIndexResponse)
def index_document(request: KnowledgeDocumentIndexRequest):
    knowledge_service = get_knowledge_service()
    return knowledge_service.index_markdown_document(request)

这个接口一般不是学生直接调用,而是 Java 后台管理端在上传知识库文档后调用。

Java 负责保存知识库记录、文档记录、文件路径、文档状态。

Python 负责把文档内容变成向量数据,写进 Milvus。

5. 一次知识库入库的完整流程

知识库入库主流程在:

YanQue-AI/src/yanque_ai/knowledge/service.py

核心三行:

markdown_text = self._loader.load_markdown(request)
nodes = self._chunker.split_markdown(request, markdown_text)
self._vector_store.replace_document(request, nodes)

它背后的流程是:

知识库入库流程

这三行分别对应:

第一步,读取 Markdown。

第二步,使用 LlamaIndex 把 Markdown 切成 Node。

第三步,使用 LlamaIndex + Embedding 模型 + MilvusVectorStore 写入 Milvus。

这里 KnowledgeService 只做编排,不自己处理所有细节:

KnowledgeDocumentLoader 负责加载文档。

KnowledgeChunker 负责把文档切成 Node。

KnowledgeVectorStore 负责向量化、写入、检索和查看 chunk。

6. LlamaIndex 在代码里的位置

这一部分用来把第四天讲过的 LlamaIndex 概念和真实代码对应起来。

项目里主要用了这些 LlamaIndex 对象:

LlamaIndex 对象和项目代码关系

6.1 Document:一整份资料

代码位置:

YanQue-AI/src/yanque_ai/knowledge/chunker.py

构建 Document 的代码:

return Document(
    text_resource=MediaResource(text=markdown),
    id_=request.document_id,
    metadata=metadata,
)

在项目里,一份 Markdown 文档会先变成一个 LlamaIndex Document

text_resource 放正文。

id_ 放 Java 传来的文档 ID。

metadata 放知识库 ID、文档 ID、文档名、版本、来源等业务字段。

6.2 Node:切分后的资料片段

代码里先用:

self._markdown_parser = MarkdownNodeParser()

再用:

self._sentence_splitter = SentenceSplitter(
    chunk_size=settings.knowledge_chunk_size,
    chunk_overlap=settings.knowledge_chunk_overlap,
)

这里的设计是两层切分:

先按 Markdown 标题切。

如果标题下面内容太长,再按句子做二次切分。

这样既能保留课程文档的章节结构,又能避免单个 chunk 太长。

6.3 MilvusVectorStore:连接 Milvus 的适配器

代码位置:

YanQue-AI/src/yanque_ai/knowledge/vector_store.py

核心代码:

return MilvusVectorStore(
    uri=self._settings.milvus_uri,
    token=self._settings.milvus_token or "",
    collection_name=collection_name,
    dim=vector_dim,
    doc_id_field=self._settings.knowledge_doc_id_field,
    text_key=self._settings.knowledge_text_field,
    embedding_field=self._settings.knowledge_embedding_field,
    overwrite=False,
    similarity_metric="COSINE",
    output_fields=["knowledge_base_id", "document_id", "document_name", "chunk_index", "version", "source"],
    use_async_client=False,
)

这段代码说明:项目没有把 pymilvus 操作写在各个业务方法里,而是通过 LlamaIndex 的 MilvusVectorStore 适配器统一连接 Milvus。

6.4 StorageContext:告诉 LlamaIndex 向量写到哪里

代码:

storage_context = StorageContext.from_defaults(vector_store=vector_store)

StorageContext 的作用是告诉 LlamaIndex:

后面生成的向量,会通过这个 vector_store 写入 Milvus。

6.5 VectorStoreIndex:触发入库和构建检索入口

入库时:

VectorStoreIndex(
    nodes,
    storage_context=storage_context,
    embed_model=self._embed_model,
)

这里会触发:

Node 正文
  ↓
Embedding 模型
  ↓
向量
  ↓
MilvusVectorStore
  ↓
Milvus Collection

检索时:

index = VectorStoreIndex.from_vector_store(
    vector_store=vector_store,
    embed_model=self._embed_model,
)

这表示从已有 Milvus 向量库构建一个 LlamaIndex 检索入口。

6.6 Retriever:真正执行相似度检索

检索代码:

retriever = index.as_retriever(
    similarity_top_k=request.limit,
    filters=self._build_search_filters(request),
)
retrieved_nodes = retriever.retrieve(request.question)

Retriever 做的事情是:

把用户问题转成向量。

去 Milvus 找相似的 Node。

返回命中的 Node 和相似度分数。

所以学生问答里说“查知识库”,在代码层面就是 Retriever.retrieve(question)

7. 为什么要保存 metadata

构建 Document 时会放入 metadata:

metadata = {
    **request.metadata,
    "knowledge_base_id": request.knowledge_base_id,
    "document_id": request.document_id,
    "document_name": request.document_name,
    "version": request.version or "",
    "source": self._resolve_source(request),
}

这些字段不是为了让模型直接阅读,而是为了工程能力:

可以知道命中的资料属于哪个知识库。

可以知道命中的 chunk 来自哪份文档。

可以在前端或日志里展示来源。

可以按 knowledge_base_iddocument_id 控制检索范围。

可以在文档更新时按 document_id 删除旧 chunk。

在 RAG 项目里,metadata 是连接“业务文档”和“向量片段”的关键。保存好 metadata,系统才能说明“这个答案参考了哪份资料”。

8. 为什么重新入库要先删除旧 chunk

入库代码里有这一段:

if self._collection_exists(vector_store, collection_name):
    vector_store.delete(ref_doc_id=request.document_id)

意思是同一份文档重新入库时,先删旧 chunk,再写新 chunk。

为什么要这样做?

因为课程资料会更新。

如果旧 chunk 不删除,Milvus 里会同时存在旧版本和新版本。学生提问时,Retriever 可能命中过期资料,模型就可能基于旧内容回答。

所以知识库项目除了“新增文档”,还要考虑“更新文档”和“重建索引”。

9. 学生问答入口

学生问答的 Python 入口在:

YanQue-AI/src/yanque_ai/api/chat_api.py

流式接口:

@ai_chat_router.post("/ai-chat/stream")
def stream_chat(request: ChatStreamRequest):

它返回:

StreamingResponse(
    event_stream(),
    media_type="text/event-stream",
)

也就是 SSE 流式响应。

Python 会依次返回这些事件:

message_start
chunk
done
error

message_start 表示模型开始回答。

chunk 表示一段增量文本。

done 表示回答结束,里面会带 token、是否使用知识库、资料引用等信息。

error 表示出错。

10. 一次学生提问的核心时序

这张图比单纯列步骤更好理解:

一次学生提问的时序

Java 负责学生身份、会话和消息保存。

Python 负责 RAG 和模型调用。

Milvus 只负责检索相似 chunk。

大模型只根据最终拼好的 messages 生成回答。

11. ChatService:问答流程总入口

问答主流程在:

YanQue-AI/src/yanque_ai/chat/service.py

核心方法:

def stream_chat(self, request: ChatStreamRequest) -> Iterator[str | ChatDonePayload]:
    tokens: int | None = None
    rag_result = self._retrieve_rag_context(request)
    messages = self._prompt_builder.build(request.history, request.message, rag_result.hits)

    for content, usage_tokens in self._llm_provider.stream_chat(messages):
        ...

这几行串起了问答主流程:

先调用 _retrieve_rag_context(),判断并检索知识库。

再调用 PromptBuilder,把系统提示词、知识库资料、历史消息、本次问题拼成 messages。

最后调用 LlmProvider.stream_chat(),让模型流式生成回答。

所以 ChatService 可以理解为问答流程的编排层,不只是一个调用模型的工具类。

12. 知识库路由:判断本次问题是否需要查资料

知识库路由在:

YanQue-AI/src/yanque_ai/chat/knowledge_router.py

入口:

use_knowledge, route_reason = self._router.should_use_knowledge(request.message)

当前有三层判断:

第一层,看配置是否强制使用知识库。

AI_CHAT_FORCE_KNOWLEDGE_BASE=true
AI_CHAT_FORCE_KNOWLEDGE_BASE=false

第二层,关键词判断。

比如问题里有这些词:

课程、课件、讲义、作业、提交、老师、班级、第几天、上节课

就优先认为需要查知识库。

第三层,调用一个更轻量的路由模型判断。

这个路由模型只回答一件事:本次问题是否需要检索内部知识库。

如果路由模型异常,代码会保守地走知识库:

return True, "知识库路由判断失败,保守使用知识库"

这个设计的目的,是减少模型在缺少资料时生成无依据回答。

如果学生问“第四天讲的 LlamaIndex 怎么接 Milvus”,这类问题明显依赖课程资料,就应该优先检索知识库,再让模型结合资料回答。

13. ChatRagService:检索资料并整理引用

RAG 检索逻辑在:

YanQue-AI/src/yanque_ai/chat/rag_service.py

核心代码:

hits = self._search_knowledge(request, knowledge_base_id)
return ChatRagResult(
    hits=hits,
    route_reason=route_reason,
    references=self._build_references(hits),
)

hits 是给 Prompt 用的完整检索结果。

references 是给 done 事件返回的资料引用。

为什么要分开?

因为 Prompt 需要尽量完整的上下文,而引用展示只需要文档名、chunk 序号、相似度和一小段摘要。

这样既能给模型提供足够的参考资料,也能让前端拿到更简洁的引用信息。

14. PromptBuilder:把资料变成模型能读的 messages

Prompt 拼接在:

YanQue-AI/src/yanque_ai/chat/prompt_builder.py

核心顺序:

messages = [
    ChatMessage(role="system", content=self._settings.ai_chat_system_prompt),
]
if knowledge_hits:
    messages.append(ChatMessage(role="system", content=self._build_knowledge_prompt(knowledge_hits)))
messages.extend(history)
messages.append(ChatMessage(role="user", content=question))

最终 messages 是:

系统人设
  ↓
知识库参考资料
  ↓
历史对话
  ↓
本次学生问题

知识库资料会被拼成:

[资料1] 文档:xxx,片段:1,相似度:0.82
这里是资料正文

[资料2] 文档:xxx,片段:2,相似度:0.76
这里是资料正文

并且会明确告诉模型:

资料没有提到的内容,要说明“知识库资料里没有明确说明”,不要自己补充没有依据的内容。

这就是 RAG 的关键点:

不只是把资料查出来,还要让模型知道它应该优先依据这些资料回答。

15. 为什么要限制 RAG 上下文长度

配置里有:

ai_chat_rag_max_chars_per_chunk: int = int(os.getenv("AI_CHAT_RAG_MAX_CHARS_PER_CHUNK", "900"))
ai_chat_rag_max_total_chars: int = int(os.getenv("AI_CHAT_RAG_MAX_TOTAL_CHARS", "3500"))

对应 PromptBuilder 里的截断逻辑。

为什么要限制?

因为模型上下文有长度限制。

检索出来的 chunk 越多,Prompt 越长,速度越慢,成本越高。

而且内容太多时,真正关键的资料反而会被淹没。

所以 RAG 不是“查到多少就全部放进去”,而是查到之后还要筛选、截断、排序和控制长度。

16. 大模型 Provider 抽象

这一块是理解后续模型扩展的重点。

模型调用代码在:

YanQue-AI/src/yanque_ai/providers/llm_provider.py

里面先定义了一个基类:

class LlmProvider:
    def chat(self, messages: list[ChatMessage]) -> str:
        raise NotImplementedError

    def chat_with_model(self, model: str, messages: list[ChatMessage]) -> str:
        raise NotImplementedError

    def stream_chat(self, messages: list[ChatMessage]) -> Iterator[tuple[str, int | None]]:
        raise NotImplementedError

这就是模型供应商抽象。

ChatService 不和某一个具体模型平台绑定,也不需要直接操作 OpenAI SDK。

它只依赖 LlmProvider 这组方法。

模型扩展关系可以看这张图:

模型 Provider 扩展设计

现在已经有两个实现:

BailianLlmProvider:真实调用百炼兼容 OpenAI 的接口。

MockLlmProvider:本地联调时模拟流式输出。

17. 为什么要这样设计模型层

如果 ChatService 里直接写:

client = OpenAI(...)
client.chat.completions.create(...)

代码可以运行,但后续会遇到几个不方便的地方。

第一,后面换模型要改问答主流程。

第二,路由模型、聊天模型、Mock 模型的职责不够清楚。

第三,本地联调需要依赖真实 API Key。

第四,单元测试和流式链路测试不好做。

现在抽象成 LlmProvider 后,问答流程只关心三件事:

普通回答:chat()

指定模型回答:chat_with_model()

流式回答:stream_chat()

至于底层是百炼、OpenAI、豆包、智谱,还是本地 Ollama,都可以放到不同 Provider 里实现。

18. 当前百炼 Provider 做了什么

真实模型实现是:

class BailianLlmProvider(LlmProvider):

初始化时:

self._client = OpenAI(
    api_key=your_placeholder
    base_url=settings.bailian_base_url,
)

这里用的是 OpenAI SDK,但实际调用的是百炼的 OpenAI 兼容接口。

百炼提供了兼容 OpenAI 调用格式的服务,所以代码里可以用 OpenAI SDK,并传入百炼的 base_url

流式调用时:

stream = self._client.chat.completions.create(
    model=self._model,
    messages=[message.model_dump() for message in messages],
    stream=True,
    stream_options={"include_usage": True},
)

这里有一个细节:

stream_options={"include_usage": True} 用来让最后的流式响应带 token 统计。

代码里如果某个 chunk 没有 choices,就会尝试读取 usage:

if not chunk.choices:
    usage = getattr(chunk, "usage", None)
    tokens = getattr(usage, "total_tokens", None) if usage else None
    if tokens is not None:
        yield "", tokens
    continue

所以 stream_chat() 返回的不是纯字符串,而是:

Iterator[tuple[str, int | None]]

第一个值是文本增量。

第二个值是 token 数,通常只在最后出现。

19. Mock Provider 的作用

代码里还有:

class MockLlmProvider(LlmProvider):

它的作用是本地联调。

打开配置:

AI_CHAT_MOCK_ENABLED=true

就可以不依赖真实 API Key,也能看到连续 chunk 输出。

这对调试 Java SSE、前端流式接收、接口事件格式都很有用。

因为有时候我们只是想验证链路是否通畅,不一定需要真实调用模型。

20. Provider 是怎么被装配进 ChatService 的

装配逻辑在:

YanQue-AI/src/yanque_ai/services/dependencies.py

核心代码:

settings = get_settings()
llm_provider = build_llm_provider(settings)
...
return ChatService(
    settings=settings,
    llm_provider=llm_provider,
    prompt_builder=ChatPromptBuilder(settings),
    rag_service=rag_service,
)

build_llm_provider()llm_provider.py 里:

def build_llm_provider(settings: Settings) -> LlmProvider:
    if settings.ai_chat_mock_enabled:
        return MockLlmProvider()
    return BailianLlmProvider(settings)

也就是说,模型选择集中在一个地方。

API 层不用判断。

ChatService 不用判断。

RAG 逻辑不用判断。

后面要接入新的模型供应商,可以新增一个 Provider,并调整 build_llm_provider() 的选择逻辑。

21. 后续怎么扩展别的模型

比如以后要接入一个新的模型供应商,可以新增:

class DoubaoLlmProvider(LlmProvider):
    def chat(self, messages: list[ChatMessage]) -> str:
        ...

    def chat_with_model(self, model: str, messages: list[ChatMessage]) -> str:
        ...

    def stream_chat(self, messages: list[ChatMessage]) -> Iterator[tuple[str, int | None]]:
        ...

然后在配置里加:

AI_CHAT_PROVIDER=doubao

再把构建逻辑改成类似:

def build_llm_provider(settings: Settings) -> LlmProvider:
    if settings.ai_chat_mock_enabled:
        return MockLlmProvider()
    if settings.ai_chat_provider == "doubao":
        return DoubaoLlmProvider(settings)
    return BailianLlmProvider(settings)

这样新增模型时,下面这些问答主流程代码基本不用改:

chat_api.py

ChatService

ChatRagService

PromptBuilder

KnowledgeService

这就是抽象层的价值。

业务流程不需要关心具体模型厂商,模型厂商变化也不会影响主流程。

22. 配置层的设计

配置集中在:

YanQue-AI/src/yanque_ai/core/config.py

这里可以分成几类:

模型配置:

bailian_api_key
bailian_base_url
bailian_model
ai_chat_router_model

Embedding 配置:

bailian_embedding_model
bailian_embedding_batch_size
knowledge_vector_dim

RAG 配置:

ai_chat_default_knowledge_base_id
ai_chat_default_knowledge_document_id
ai_chat_knowledge_top_k
ai_chat_force_knowledge_base
ai_chat_rag_max_chars_per_chunk
ai_chat_rag_max_total_chars

Milvus 配置:

milvus_uri
milvus_token
milvus_db_name
knowledge_collection_name
knowledge_doc_id_field
knowledge_text_field
knowledge_embedding_field

切分配置:

knowledge_chunk_size
knowledge_chunk_overlap

这些配置没有分散写在业务代码里,而是统一从 Settings 读取。

好处是:

部署时可以通过环境变量调整。

代码里可以减少硬编码。

后续换模型、换向量维度、调 top_k、调 chunk_size 都更清楚。

23. Java 在问答链路里的职责

Java 学生端接口在:

YanQue-Admin/src/main/java/cn/yanque/studentFront/controller/StudentAiChatController.java

核心接口:

POST /student/ai-chat/sessions/{sessionId}/messages/stream

Java Biz 层在:

YanQue-Admin/src/main/java/cn/yanque/studentFront/biz/impl/StudentAiChatBizImpl.java

它做这些事:

获取当前登录学生 ID。

校验会话归属。

查询最近历史消息。

组装 Python 请求。

保存 user 消息。

异步调用 Python。

把 Python 返回的 chunk 转发给前端。

累加完整 assistant 内容。

done 时保存 assistant 消息。

注意一个细节:

PythonAiChatStreamReq pythonReq = buildPythonReq(studentId, sessionId, message);
studentAiChatService.saveUserMessage(studentId, sessionId, message);

这里先构造 Python 请求,再保存当前 user 消息。

原因是 Python 请求里的 history 应该只包含历史消息,本次问题已经放在 message 字段里,不需要再重复放入历史消息。

24. Java 如何转发 Python SSE

Java 调 Python 的代码在:

YanQue-Admin/src/main/java/cn/yanque/studentFront/client/ai/PythonAiChatClient.java

关键代码:

try (HttpResponse response = buildStreamRequest(req).executeAsync()) {
    validateResponse(response);
    readSse(response, handler);
}

这里用 executeAsync() 是为了拿到响应流,让 Java 可以一边接收、一边处理。

如果等 Python 全部回答完再处理,前端就只能看到一次性返回的结果,看不到逐段输出的效果。

Java 会逐行读取 Python 的 SSE:

BufferedReader reader = new BufferedReader(
    new InputStreamReader(response.bodyStream(), StandardCharsets.UTF_8)
)

然后根据事件名分发:

case EVENT_MESSAGE_START -> handler.onStart(body.getString("model"));
case EVENT_CHUNK -> handler.onChunk(body.getString("content"));
case EVENT_DONE -> handler.onDone(body.toJavaObject(PythonAiChatDone.class));
case EVENT_ERROR -> handler.onError(body.getString("message"));

PythonAiChatClient 只负责 HTTP 和 SSE 解析。

保存消息由 Biz 层做。

这也体现了分层设计:客户端层负责通信,业务状态由 Biz 层处理。

25. 前端在这条链路里做什么

前端部分在这里简单带过。

这节课先记住两点:

第一,前端请求 Java 的流式接口,由 Java 继续调用 Python。

第二,前端收到 chunk 事件后,把增量文本追加到当前 assistant 消息上。

所以前端的职责就是:

发送问题。

接收 SSE。

展示增量文本。

结束后重新加载消息列表,保证页面和数据库一致。

页面怎么布局、按钮怎么写、样式怎么调,后面讲前端时再展开。

26. 一次提问的完整数据流

把所有内容合起来,一次提问是这样走的:

1. 学生发送问题
2. 前端请求 Java 流式接口
3. Java 校验学生身份和会话归属
4. Java 查询历史消息,组装 Python 请求
5. Java 保存本次 user 消息
6. Java 通过 PythonAiChatClient 请求 Python /api/ai-chat/stream
7. Python 的 ChatService 开始编排问答流程
8. ChatKnowledgeRouter 判断是否需要查知识库
9. 如果需要,ChatRagService 调 KnowledgeService 检索
10. KnowledgeVectorStore 使用 VectorStoreIndex + Retriever 从 Milvus 查 chunk
11. PromptBuilder 拼接系统提示词、知识库资料、历史消息和本次问题
12. ChatService 调 LlmProvider.stream_chat()
13. 具体 Provider 调用模型厂商接口
14. Python 把模型输出转换成 message_start、chunk、done、error
15. Java 解析 Python SSE,并继续用 SseEmitter 转发
16. Java 累加完整 assistant 内容
17. done 时 Java 保存 assistant 消息
18. 前端持续追加 chunk,形成流式输出效果

这条链路步骤比较多,但每一层的职责是清楚的:

Java 管业务。

Python 管 AI。

LlamaIndex 管 RAG 工程化。

Milvus 管向量检索。

Provider 管模型供应商差异。

27. 这套设计里最重要的几个点

第一,API 层保持简洁。

chat_api.pyknowledge_api.py 主要负责接收请求和返回响应,复杂流程放到 service 层。

第二,LlamaIndex 在项目里有清楚的代码位置。

项目里明确用到了 DocumentMarkdownNodeParserSentenceSplitterMilvusVectorStoreStorageContextVectorStoreIndexRetriever

第三,RAG 不只是检索。

它还包括知识库路由、检索范围控制、metadata、Prompt 拼接、上下文长度控制和来源返回。

第四,模型调用要抽象。

ChatService 只依赖 LlmProvider。以后扩展别的模型,优先新增 Provider,问答主流程保持稳定。

第五,流式输出是端到端的。

Python 流给 Java,Java 再流给前端,中间要保持逐段读取和逐段转发。

第六,本次问题不需要重复放进 history。

历史消息是历史消息,本次问题是本次问题,两者要分开。

第七,文档更新要清理旧 chunk。

这样可以避免知识库里同时保留旧版本资料和新版本资料。

28. 学完这套代码要带走什么

这套学生端 AI 知识库问答,已经是一个完整 AI 应用的雏形。

它不只是模型 API 调用。

它包含:

知识库入库。

Markdown 切分。

Embedding 向量化。

Milvus 存储。

LlamaIndex 检索。

知识库路由。

RAG Prompt 拼接。

模型 Provider 抽象。

流式模型调用。

Java SSE 桥接。

消息落库。

来源返回。

真正的 AI 应用开发,重点不是只会写一句模型调用代码。

更重要的是把大模型能力放进真实业务系统里,并且让它可扩展、可追踪、可调试、可替换。