本文介绍如何用阿里云 Milvus 的六个 AI Function 串联一条客服语音处理链路:录音写入时自动转写为文本、文本入库前自动脱敏 PII、FAQ 写入即向量化,再经语义召回与大模型重排精准匹配标准答案,并对会话自动完成情感识别与工单分类。
方案概述
在线客服与电话客服每天沉淀的最大一笔数据资产,是海量通话录音和语音留言。一个中等规模的客服中心单日通话动辄数万通、录音时长以千小时计。这些语音里有最真实的客户诉求、坐席服务质量与潜在舆情风险,但多数团队至今仍把它们当作合规留档的冷数据。要把语音资产盘活,需要做到:
录音转文字:把通话与语音留言批量转写成可检索、可分析的文本,这是后续一切工作的前提。
建可检索知识库:把历史优质应答、产品手册、FAQ 沉淀成语义可检索的知识库,供机器人和坐席实时调用。
FAQ 智能匹配:用户一句口语化的问题要能准确命中标准 FAQ,而不是靠关键词碰运气。
情感与意图识别:识别客户在通话中的情绪波动,支撑实时预警与事后质检。
质检与合规脱敏:转写文本常包含手机号、身份证号、银行卡号等个人敏感信息,入库、分析、共享前必须脱敏。
传统方案要凑齐这套能力,通常需要串联 ASR 服务、向量库、外部 NLP 平台与重排服务四套以上系统。数据在多个系统间来回搬运,胶水代码越写越厚,任何一环抖动都会拖垮整条链路;原始转写文本流出到外部服务还存在个人敏感信息泄露风险,而脱敏往往被放到链路末端甚至遗漏。
阿里云 Milvus 的 AI Function 把这些能力收敛进同一个向量数据库,模型推理在写入与检索时由 Milvus 内部自动触发,数据全程不出实例。本文用到六个 Function:
Function | 作用 | 在客服语音链路中的用途 |
| 写入时自动把录音转写成文本,无需应用侧先调 ASR。 | 把海量通话录音变成可检索、可分析的文本。 |
| 自动掩码文本中的手机号、证件号、银行卡号等敏感信息。 | 让进入知识库与分析库的都是已脱敏文本,合规前置。 |
| 写入时自动把 FAQ 文本转成向量。 | 让「实例怎么连外网」这类口语化需求能被语义检索命中。 |
| 对召回候选按与查询的相关性重新排序。 | 把最贴合意图的 FAQ 顶到首位,修正向量召回的噪声。 |
| 判断会话情感倾向。 | 聚合情感标签,支撑全量质检与舆情预警。 |
| 把会话归类到工单类目。 | 支撑工单自动分类与路由。 |
前提条件
已创建 Milvus 2.6 版本实例。AI Function 依赖 2.6 版本内核,创建后无需单独绑定模型服务。
如需从公网访问实例,已在实例详情页的 安全配置 页签开启 公网访问 并将客户端出口 IP 加入公网访问白名单。
已安装 pymilvus,本文示例基于 pymilvus 3.0.0 验证。
待转写的录音已上传到服务端可下载的真实地址。
RESTful 接口与 gRPC 共用 19530 端口,调用时必须显式带端口,例如 http://c-xxx.milvus.aliyuncs.com:19530;省略端口会默认访问 80 端口并导致连接超时。
音频地址必须是服务端能真实下载的地址,生产环境建议使用自有 OSS 的短时效签名 URL。如果传入的是占位符或已失效的地址,会报 Failed to download multimodal content。
操作步骤
准备公共代码
以下代码包含连接配置、REST 调用封装以及 TEXTTRANSFORM 函数类型的兼容兜底,供后续步骤共用。请将 MILVUS_URI 与 MILVUS_TOKEN 替换为实际实例信息。
from __future__ import annotations
import json
import time
from typing import Any
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from pymilvus import DataType, Function, FunctionType, MilvusClient
# ==================== 连接配置 ====================
MILVUS_URI = "http://c-xxx.milvus.aliyuncs.com:19530" # 端口必须写 19530
MILVUS_TOKEN = "root:xxx"
MILVUS_REST_BASE_URL = MILVUS_URI
client = MilvusClient(uri=MILVUS_URI, token=MILVUS_TOKEN)
# AI_AUDIO_TRANSCRIBE / AI_PII_MASK / AI_SENTIMENT / AI_CLASSIFY 都属于 TEXTTRANSFORM
# 类型的 Function(函数类型值 9),通过 task 参数区分具体任务。
# 部分 pymilvus 版本的 FunctionType 枚举中没有该成员,下面做一次兼容兜底。
TEXTTRANSFORM_FUNCTION_TYPE = 9
def texttransform_function_type() -> Any:
for type_name in ("TEXTTRANSFORM", "TEXT_TRANSFORM", "TextTransform"):
ft = getattr(FunctionType, type_name, None)
if ft is not None:
return ft
existing = getattr(FunctionType, "_value2member_map_", {}).get(TEXTTRANSFORM_FUNCTION_TYPE)
if existing is not None:
return existing
extension = int.__new__(FunctionType, TEXTTRANSFORM_FUNCTION_TYPE)
extension._name_ = "TEXTTRANSFORM"
extension._value_ = TEXTTRANSFORM_FUNCTION_TYPE
FunctionType._value2member_map_[TEXTTRANSFORM_FUNCTION_TYPE] = extension
FunctionType._member_map_["TEXTTRANSFORM"] = extension
return extension
def post_json(path: str, body: dict[str, Any], timeout: int = 120,
retries: int = 3) -> tuple[int, dict[str, Any]]:
"""REST 即时接口统一封装(走大模型,加重试更稳)。"""
last: tuple[int, dict[str, Any]] | None = None
for _ in range(retries):
request = Request(
f"{MILVUS_REST_BASE_URL.rstrip('/')}{path}",
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
headers={"Authorization": f"Bearer {MILVUS_TOKEN}",
"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(request, timeout=timeout) as response:
status, data = response.status, json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
status, data = exc.code, json.loads(exc.read().decode("utf-8"))
last = (status, data)
if status == 200 and data.get("code") == 0:
return status, data
time.sleep(1)
return lastAI_AUDIO_TRANSCRIBE、AI_PII_MASK、AI_SENTIMENT、AI_CLASSIFY 都属于 TEXTTRANSFORM 类型的 Function(函数类型值 9),通过 task 参数区分具体任务;AI_EMBEDDING 与 AI_RERANK 则有独立的 FunctionType 常量。
步骤一:录音转写
把通话录音批量转写成文本。将 AI_AUDIO_TRANSCRIBE 挂在 Collection 上后,写入音频地址时自动产出转写文本,无需应用侧先调用 ASR 服务。
# ==================== 步骤 1:AI_AUDIO_TRANSCRIBE 录音转写 ====================
collection_name = "cs_transcribe"
if client.has_collection(collection_name):
client.drop_collection(collection_name)
schema = MilvusClient.create_schema(auto_id=True, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("audio_url", DataType.VARCHAR, max_length=4096) # 音频输入字段
schema.add_field("transcript", DataType.VARCHAR, max_length=4096) # 转写输出字段
# Collection 必须至少包含一个向量字段。本步骤不做向量检索,用 2 维占位字段满足约束;
# 声明为 nullable=True 后,insert 时无需再传该字段。
schema.add_field("dummy_vector", DataType.FLOAT_VECTOR, dim=2, nullable=True)
schema.add_function(
Function(
name="transcribe_audio",
function_type=texttransform_function_type(),
input_field_names=["audio_url"],
output_field_names=["transcript"],
params={
"provider": "aliyun_milvus",
"model_name": "qwen3-asr-flash",
"task": "ai_audio_transcribe",
"language": "zh",
"enable_itn": "true", # 规范化口语数字,如「一三八」转为 138
},
)
)
index_params = client.prepare_index_params()
index_params.add_index(field_name="dummy_vector", index_type="AUTOINDEX", metric_type="COSINE")
client.create_collection(collection_name=collection_name, schema=schema,
index_params=index_params)
# audio_url 必须是服务端可下载的真实地址,生产环境用自有 OSS 的短时效签名 URL
audio_urls = [
"https://<your-bucket>.oss-cn-hangzhou.aliyuncs.com/calls/call_0001.mp3",
"https://<your-bucket>.oss-cn-hangzhou.aliyuncs.com/calls/call_0002.wav",
]
client.insert(collection_name, [{"audio_url": u} for u in audio_urls])
client.flush(collection_name)
for row in client.query(collection_name, filter="",
output_fields=["audio_url", "transcript"], limit=10):
print(f"{row['audio_url'].split('/')[-1]} -> {row['transcript']}")enable_itn 开启逆文本规范化,会把口语化的数字念法转成规范写法,便于后续检索与结构化处理。
Collection 必须至少包含一个向量字段,否则建表报 schema does not contain vector field。本步骤不做向量检索,因此用一个 2 维占位字段满足约束。把它声明为 nullable=True 后,insert 时就不必再传该字段;若不加 nullable,省略该字段会报 Insert missed an field dummy_vector。
步骤二:PII 脱敏
转写文本常包含手机号、身份证号、银行卡号等个人敏感信息。在文本进入知识库或分析库之前完成脱敏,把合规环节前置,而不是留到链路末端。提供两种用法:REST 即时接口,以及写入型 Collection。
# ==================== 步骤 2:AI_PII_MASK 脱敏 ====================
# 2.1 REST 即时接口:文本进库前先脱敏
status, data = post_json(
"/v2/vectordb/ai/pii_mask",
{
"model_name": "qwen3.7-max",
"texts": ["您好,我的手机号是 13812345678,身份证 110101199003071234,麻烦帮我查下工单。"],
"params": {
"pii_types": ["PERSON", "PHONE", "ID_CARD"],
"mask_char": "*",
"preserve_length": True, # 掩码后保持原长度
"temperature": 0,
},
},
)
assert status == 200 and data.get("code") == 0, data
for item in data["data"]["output"]["outputs"]:
print(item)
# 2.2 写入型 Collection:content 入库时自动生成脱敏字段 masked
collection_name = "cs_pii_mask"
if client.has_collection(collection_name):
client.drop_collection(collection_name)
schema = MilvusClient.create_schema(auto_id=True, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("content", DataType.VARCHAR, max_length=4096)
schema.add_field("masked", DataType.VARCHAR, max_length=4096)
schema.add_field("dummy_vector", DataType.FLOAT_VECTOR, dim=2, nullable=True)
schema.add_function(
Function(
name="mask_pii",
function_type=texttransform_function_type(),
input_field_names=["content"],
output_field_names=["masked"],
params={
"provider": "aliyun_milvus",
"model_name": "qwen3.7-max",
"task": "ai_pii_mask",
"pii_types": "PERSON,PHONE,ID_CARD",
"mask_char": "*",
"preserve_length": "true",
"temperature": "0",
},
)
)
index_params = client.prepare_index_params()
index_params.add_index(field_name="dummy_vector", index_type="AUTOINDEX", metric_type="COSINE")
client.create_collection(collection_name=collection_name, schema=schema,
index_params=index_params)
client.insert(collection_name,
[{"content": "您好,我的手机号是 13812345678,麻烦帮我查下工单。"}])
client.flush(collection_name)
for row in client.query(collection_name, filter="", output_fields=["masked"], limit=1):
print(row["masked"])preserve_length 为 true 时掩码后保持原字符长度,便于在不暴露原值的前提下保留格式特征。实测输出:
您好,我的手机号是 ***********,身份证 ******************,麻烦帮我查下工单。11 位手机号掩码为 11 个星号,18 位身份证掩码为 18 个星号。两种用法的区别是:REST 即时接口适合在数据流入前做一次性处理,写入型 Collection 适合让脱敏结果与原文一并留存、便于审计。
步骤三:建 FAQ 知识库
把 FAQ 问题与标准答案写入 Collection,AI_EMBEDDING 在写入时自动完成向量化,应用侧无需先调用 embedding 模型。
# ==================== 步骤 3:AI_EMBEDDING 建 FAQ 知识库 ====================
collection_name = "cs_faq_kb"
if client.has_collection(collection_name):
client.drop_collection(collection_name)
schema = MilvusClient.create_schema(auto_id=True, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("content", DataType.VARCHAR, max_length=4096) # FAQ 问题文本
schema.add_field("answer", DataType.VARCHAR, max_length=4096) # 标准答案
schema.add_field("embedding", DataType.FLOAT_VECTOR, dim=1024)
schema.add_function(
Function(
name="embed_content",
function_type=FunctionType.TEXTEMBEDDING,
input_field_names=["content"],
output_field_names=["embedding"],
params={
"provider": "aliyun_milvus",
"model_name": "text-embedding-v4",
"dim": 1024,
"max_client_batch_size": 10,
"max_concurrency": 1,
},
)
)
index_params = client.prepare_index_params()
index_params.add_index(
field_name="embedding",
index_type="HNSW",
metric_type="COSINE",
params={"M": 16, "efConstruction": 200},
)
client.create_collection(collection_name=collection_name, schema=schema,
index_params=index_params)
faqs = [
{"content": "如何开启 Serverless Milvus 实例的公网访问?",
"answer": "在控制台实例详情页开启公网并配置白名单。"},
{"content": "忘记控制台登录密码怎么办?", "answer": "通过账号中心的找回密码流程重置。"},
{"content": "账单为什么比预期高?", "answer": "查看用量明细,重点关注计算与存储用量。"},
{"content": "如何创建 Collection 并写入向量?",
"answer": "使用 create_collection 定义 schema 后 insert。"},
{"content": "实例扩容会影响在线业务吗?", "answer": "扩容为在线操作,通常不中断服务。"},
]
client.insert(collection_name, faqs)
client.flush(collection_name)
client.load_collection(collection_name)本例使用 text-embedding-v4(支持多语言与自定义维度)。AI 中心还提供 qwen3.7-text-embedding 等模型,可在控制台 AI 中心 的 模型服务 页签查看当前可用模型及其维度,按语种与效果需求选型。向量字段的 dim 必须与 Function 参数中的 dim 一致。
步骤四与步骤五:语义召回与大模型重排
用户的口语化问题先经向量检索召回 Top-N 候选,再由重排模型按与查询的真实相关性精排。这两步合起来才能稳定命中标准 FAQ。
# ==================== 步骤 4:语义检索召回 FAQ ====================
query = "我的实例怎么才能让外网连上?"
result = client.search(
collection_name="cs_faq_kb",
data=[query],
anns_field="embedding",
limit=3,
output_fields=["content", "answer"],
)
for rank, hit in enumerate(result[0], 1):
print(f"{rank}. [相似度 {hit['distance']:.4f}] {hit['entity']['content']}")
# ==================== 步骤 5:AI_RERANK 重排 ====================
# 5.1 REST 即时接口:对给定的一批候选独立重排
status, data = post_json(
"/v2/vectordb/ai/rerank",
{
"model_name": "qwen3-rerank",
"query": query,
"documents": [
"如何开启 Serverless Milvus 实例的公网访问?",
"实例扩容会影响在线业务吗?",
"如何创建 Collection 并写入向量?",
],
"params": {"max_concurrency": 2, "timeout_sec": 10},
},
)
assert status == 200 and data.get("code") == 0, data
ranked = sorted(data["data"]["output"]["results"],
key=lambda x: x["relevance_score"], reverse=True)
for rank, item in enumerate(ranked, 1):
print(f"{rank}. [相关度 {item['relevance_score']:.4f}] 候选 index={item['index']}")
# 5.2 在 search 时挂载 ranker,召回与重排一次完成
reranker = Function(
name="rerank_faq",
function_type=FunctionType.RERANK,
input_field_names=["content"],
params={
"reranker": "model",
"provider": "aliyun_milvus",
"model_name": "qwen3-rerank",
"queries": [query],
"max_concurrency": 2,
"timeout_sec": 10,
},
)
result = client.search(
collection_name="cs_faq_kb",
data=[query],
anns_field="embedding",
limit=3,
output_fields=["content", "answer"],
ranker=reranker,
)
for rank, hit in enumerate(result[0], 1):
e = hit["entity"]
print(f"{rank}. [重排分 {hit['distance']:.4f}] {e['content']} | 答:{e['answer']}")以查询「我的实例怎么才能让外网连上?」为例,实测数据如下。这组前后对比很能说明重排的价值:
候选 FAQ | 步骤四 向量召回 | 步骤五 重排后 |
如何开启 Serverless Milvus 实例的公网访问? | ① 0.6141 | ① 0.5883 |
忘记控制台登录密码怎么办? | ② 0.5613 | ③ 0.2607 |
实例扩容会影响在线业务吗? | ③ 0.4757 | ② 0.3356 |
向量召回把「忘记控制台登录密码怎么办?」排到了第二位(0.5613),但它与「让外网连上」在意图上并不相关——这正是纯向量检索「召回但不精准」的典型表现:文本表面相似度不低,实际意图相去甚远。经重排后该条被压到第三位(0.2607),语义更接近的「实例扩容」升到第二位。对客服机器人而言,Top-1 的准确性直接决定应答质量,因此重排环节不可省略。
两种重排用法的区别:REST 即时接口适合已经拿到候选列表、只需重排的场景;在 search 中挂载 ranker 则把召回与精排合并为一次调用,链路更短。REST 接口不支持 top_n,会为每个候选各返回一个得分,需要截断时在应用侧按得分排序后自行取前 N 条。
纯文本重排不需要 is_multimodal 参数。该参数仅在多模态重排(用 qwen3-vl-rerank 对图片、视频候选打分)时才需要,对纯文本候选传入不会改变打分结果。
步骤六:情感识别与工单分类
对脱敏后的会话文本同时挂载两个 Function:一个判断情感倾向用于质检与舆情预警,一个归类到工单类目用于自动分流。两者都在写入时自动完成。
# ==================== 步骤 6:AI_SENTIMENT 情感识别 + AI_CLASSIFY 工单分类 ====================
collection_name = "cs_qc"
if client.has_collection(collection_name):
client.drop_collection(collection_name)
schema = MilvusClient.create_schema(auto_id=True, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("content", DataType.VARCHAR, max_length=4096) # 脱敏后的会话文本
schema.add_field("sentiment", DataType.VARCHAR, max_length=64) # 情感输出
schema.add_field("category", DataType.VARCHAR, max_length=64) # 工单类目输出
schema.add_field("dummy_vector", DataType.FLOAT_VECTOR, dim=2, nullable=True)
schema.add_function(
Function(
name="analyze_sentiment",
function_type=texttransform_function_type(),
input_field_names=["content"], output_field_names=["sentiment"],
params={"provider": "aliyun_milvus", "model_name": "qwen3.7-max",
"task": "ai_sentiment", "categories": "positive,negative,neutral",
"temperature": "0"},
)
)
schema.add_function(
Function(
name="classify_ticket",
function_type=texttransform_function_type(),
input_field_names=["content"], output_field_names=["category"],
params={"provider": "aliyun_milvus", "model_name": "qwen3.7-max",
"task": "ai_classify", "labels": "账号,咨询,故障,计费",
"prompt": "根据客户问题所属主题分类。", "temperature": "0"},
)
)
index_params = client.prepare_index_params()
index_params.add_index(field_name="dummy_vector", index_type="AUTOINDEX", metric_type="COSINE")
client.create_collection(collection_name=collection_name, schema=schema,
index_params=index_params)
client.insert(collection_name, [
{"content": "你们这个问题我已经打了三次电话了,到现在还没解决,太让人失望了!"},
{"content": "请问 Serverless Milvus 实例怎么开启公网访问?"},
])
client.flush(collection_name)
for row in client.query(collection_name, filter="",
output_fields=["content", "sentiment", "category"], limit=10):
print(f"情感={row['sentiment']:<10} 类目={row['category']:<6} | {row['content'][:28]}")实测结果:
会话文本 | 情感 | 工单类目 |
你们这个问题我已经打了三次电话了,到现在还没解决,太让人失望了! | negative | 故障 |
请问 Serverless Milvus 实例怎么开启公网访问? | neutral | 咨询 |
一个 Collection 上可以挂载多个 Function,只要它们的输出字段不冲突。本例中 sentiment 与 category 由两个 Function 各自填充,写入一条数据即同时得到情感标签与工单类目。
情感与分类结果是模型判断而非事实认定。在工单升级、服务下架等高影响动作前,应保留人工复核环节。
方案价值
维度 | 传统方案(ASR + 向量库 + NLP + 重排服务) | 阿里云 Milvus |
系统数量 | 4 套以上,数据多处搬运 | 1 套,数据全程不出实例 |
录音转写 | 应用侧先调 ASR 再写库 | 写入即转写 |
向量化链路 | 应用侧调 embedding 再写入 | 写入即向量化 |
PII 脱敏 | 常被放在链路末端,容易遗漏 | 入库即脱敏,合规前置 |
召回与重排 | 额外部署重排模型服务 |
|
情感与分类 | 外接 NLP 平台 |
|
可延伸的场景包括:
坐席实时辅助:把召回与重排接入坐席工作台,通话进行中即推送最贴切的话术与知识。
批量质检:历史录音可结合
AI_BATCH做离线全量转写、脱敏与情感分类打标,把质检覆盖率从抽检提升到全量。舆情预警:对
AI_SENTIMENT输出为negative的工单实时触发预警与升级流程。
合规注意事项
录音处理前务必完成录音告知与客户授权。
媒体一律使用短时效、最小权限的签名 URL,不要在 URL 中嵌入长期凭据。
为原始音频、转写文本、情感标签与复核记录设置明确的保留期限,到期删除或匿名化;并保证派生数据随源数据的删除或授权撤回而同步处置。
情感与分类结果是模型判断而非事实认定,在下架、升级等高影响动作前应保留人工复核环节。