基于阿里云Milvus 知识库服务,快速搭建智能客服问答系统实践

阿里云 Milvus 知识库是阿里云向量检索服务 Milvus 版(简称阿里云 Milvus)最新发布的面向企业级知识应用的高性能智能知识服务平台。它将文档解析、内容切片、向量化、混合检索、重排优化与大模型生成能力整合为一条完整链路,帮助企业快速把分散在 PDF、Word、Excel、PPT、Markdown、HTML、图片和结构化数据中的私有知识,转化为可检索、可问答、可追溯、可持续运营的知识资产。

下面我们看基于阿里云Milvus 知识库服务,如何快速搭建智能客服问答系统。

先看结果

你提供:产品手册、FAQ、退换货/保修/发票政策(建议先准备 5--20 篇 PDF、DOCX、MD 或 TXT)、一个已在阿里云控制台创建的 Milvus 知识库 ID、具备调用权限的 AK/SK,以及一个 OpenAI Chat Completions 兼容的大模型地址和 Token。

预计耗时:约 20--30 分钟(文档解析耗时取决于数量和大小)。

你将得到:得到一个可在浏览器提问、依据企业资料回答并展示来源的客服助手。

安全说明:下面全部使用占位符。不要把真实 AK/SK、模型 Token 或内部资料提交到代码仓库,也不要把它们发到聊天群。

用户应该怎么操作

  1. 在阿里云控制台创建一个 Milvus 知识库,记下 Region 和 kd- 开头的知识库 ID。标签可在知识库详情页 → 基本信息 → 标签 → 管理中补充;建议先定义本文使用的标签,以获得类型转换和控制台管理能力,但未定义的 MetaFields 也能写入和过滤。

  2. 把准备好的资料放进本地 documents/ 目录。

  3. 让 Agent 按本文代码创建项目,复制 config.example.jsonconfig.json,只在本机填入凭证、知识库 ID 和大模型配置。

  4. 运行上传命令;到控制台等待文档解析完成,然后点击"发布版本"。

  5. 启动页面,打开 http://127.0.0.1:7860 提问。

可直接交给 Agent 的指令

阅读本文后,在当前目录创建 kb-demo 项目并逐个写入文中的完整文件。不要改用手拼 POP 签名,必须使用 alibabacloud-milvusknowledgebase20260604 SDK。若缺少 Region、知识库 ID、AK/SK 或大模型配置,只询问这些缺失项;不要打印秘密。创建虚拟环境、安装依赖、上传 documents/ 中的文件。上传完成后提醒我去控制台等待解析并发布版本;我确认发布后,再启动 Flask,并用示例问题调用 /api/ask 验证,最后告诉我本地访问地址和验证结果。

项目目录

text 复制代码
kb-demo/
├── app.py
├── kb_client.py
├── upload.py
├── requirements.txt
├── config.example.json
├── config.json          # 本机配置,不提交
├── documents.jsonl      # 每篇文件及其 metadata
├── start.sh
├── documents/           # 放业务资料
└── templates/
    └── index.html

完整代码

控制台解析与标签(MetaFields)设置

控制台"创建策略"中的"最大分段长度"单位是字符。客服短 FAQ 和政策条款建议先设置 380--580 字符(约合 256--384 tokens),再根据实际切片抽查调整。

建议在知识库详情页 → 基本信息 → 标签 → 管理中定义以下标签。名称需与上传的 MetaFields 一致,类型使用控制台的小写值:

  • docType(string)

  • productLine(string)

  • effectiveDate(string)

标签定义不是 MetaFields 的写入白名单:未预先定义的字段也能写入文档/切片并参与过滤。预先定义的作用主要是控制台展示、选项管理和按 string/int64/float32/bool/list 做值类型转换;增删定义不会删除历史值。

当前正式可用的 TagFilter 操作符是 =、in、not in;in/not in 的 value 建议传 JSON 数组。其他操作符在对应产品任务完成前不要写入客户示例,避免过滤条件被静默忽略。

documents.jsonl

每行对应一篇文件。metadata 字段即 AddDocuments.MetaFields;建议先在控制台定义以获得类型校验和管理能力,但不是写入前置条件。

jsonl 复制代码
{"path": "documents/shipping-policy.md", "metadata": {"docType": "policy", "productLine": "all", "effectiveDate": "2026-01-01"}}
{"path": "documents/phone-manual.pdf", "metadata": {"docType": "manual", "productLine": "phone", "effectiveDate": "2026-03-01"}}

requirements.txt

text 复制代码
Flask==3.1.1
requests==2.32.4
alibabacloud-milvusknowledgebase20260604==1.0.0

config.example.json

复制为 config.json 后填入真实配置。生产环境建议改用环境变量或凭证服务;演示结束后及时轮换临时凭证。

json 复制代码
{
  "aliyun": {
    "access_key_id": "YOUR_ACCESS_KEY_ID",
    "access_key_secret": "YOUR_ACCESS_KEY_SECRET",
    "region_id": "cn-hangzhou",
    "knowledge_base_id": "kd-xxxxxxxx",
    "knowledge_base_version": "LATEST_PUBLISHED"
  },
  "llm": {
    "enabled": true,
    "base_url": "https://YOUR_OPENAI_COMPATIBLE_ENDPOINT/v1",
    "api_key": "YOUR_LLM_API_KEY",
    "model": "YOUR_MODEL_NAME"
  },
  "upload": {
    "default_meta_fields": {}
  },
  "retrieval": {
    "page_size": 6,
    "candidate_count": 48,
    "min_score": 0.35,
    "semantic_weight": 0.7,
    "enable_query_expansion": true,
    "rerank_model_name": "qwen3-rerank",
    "tag_filter": {
      "relation": "and",

      "conditions": [ ]

    }
  },
  "scenario": {
    "title": "案例一:智能客服知识库问答 Demo",
    "system_prompt": "你是企业客服助手。仅依据下方检索资料回答。每个事实必须能被资料直接支持并标注[来源N];资料为空、与问题仅弱相关或未明确覆盖结论时,只回答"现有资料中没有明确说明,请转人工确认",不得依据常识补写政策、价格、支付方式或承诺。",
    "image_enabled": false,
    "sample_questions": [
      "商品通常在付款后多久发货?",
      "超过保修期后还能维修吗?",
      "申请退货需要满足哪些条件?"
    ]
  }
}

本场景检索参数为什么不同

min_score=0.35:用于过滤最终 score 较低的结果;没有跨语料通用阈值,应先用真实问题和相关性标注校准。

semantic_weight=0.7:让最终分数更偏向 semanticScore。调整该值会改变 score 量纲,需要结合 scoreDetails 重新校准 min_score。

qwen3-rerank:对候选切片二次排序。模型是否可用取决于租户模型目录和供应商配置;启用或切换 Rerank 后 semanticScore 量纲会变化,需要重新校准 min_score。

scoreDetails.keywordScore 表示关键词相似度;semanticScore 在未启用 Rerank 时表示向量相似度,启用后表示重排模型分数;score 是加权并可能叠加 rank feature 后的最终排序与过滤分数。不同模型或配置下的分数不能直接横向比较。

kb_client.py

上传链路严格为:获取预签名 URL → HTTP PUT 文件二进制 → AddDocuments 注册并触发解析。PUT 时不要设置 Content-Type。GetKnowledgeBasePreSignedUrl 和 AddDocuments 单批最多 100 篇;代码会对连接异常、超时和 5xx 最多重试 3 次,普通 4xx 不重试。

去重说明:content_dedup 是知识库内整篇文档内容 hash 判重,不是切片级;doc_name_dedup 会跳过同名文档,不覆盖旧文档。若整批都被跳过,当前 AddDocuments 会返回 400 "No OSS document can be registered.",应理解为"本批没有可注册的新文档"。

python 复制代码
"""Milvus 知识库 OpenAPI SDK:本地上传与已发布版本检索。"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
import time
from typing import Any, Mapping, Sequence

import requests
from alibabacloud_milvusknowledgebase20260604 import models as models
from alibabacloud_milvusknowledgebase20260604.client import Client
from alibabacloud_tea_openapi import models as openapi_models


@dataclass(frozen=True)
class LocalDocument:
    path: Path
    object_path: str

    @classmethod
    def from_path(cls, value: str | Path) -> "LocalDocument":
        path = Path(value).expanduser().resolve()
        if not path.is_file():
            raise FileNotFoundError(path)
        return cls(path=path, object_path=path.name)


@dataclass(frozen=True)
class TagCondition:
    field: str
    op: str
    value: Any


@dataclass(frozen=True)
class SearchOptions:
    version: str = "LATEST_PUBLISHED"
    page_size: int = 6
    candidate_count: int = 48
    min_score: float = 0.0
    semantic_weight: float = 0.5
    enable_query_expansion: bool = True
    rerank_model_name: str | None = None
    tag_relation: str = "and"
    tag_conditions: tuple[TagCondition, ...] = ()


class KnowledgeBaseClient:
    def __init__(
        self,
        access_key_id: str,
        access_key_secret: str,
        region_id: str,
    ) -> None:
        endpoint = f"milvusknowledgebase.{region_id}.aliyuncs.com"
        self.client = Client(
            openapi_models.Config(
                access_key_id=access_key_id,
                access_key_secret=access_key_secret,
                region_id=region_id,
                endpoint=endpoint,
                connect_timeout=10_000,
                read_timeout=60_000,
            )
        )
        self.http = requests.Session()

    @staticmethod
    def _check(body: Any, action: str) -> None:
        if body is None:
            raise RuntimeError(f"{action} 返回空响应")
        if getattr(body, "success", None) is False or getattr(body, "code", None) not in (None, 0, "0"):
            raise RuntimeError(
                f"{action} 失败:{getattr(body, 'message', 'unknown')};"
                f"requestId={getattr(body, 'request_id', '')}"
            )

    def _put_file(self, url: str, path: Path, max_attempts: int = 3) -> None:
        for attempt in range(1, max_attempts + 1):
            try:
                with path.open("rb") as source:
                    # 不设置 Content-Type,否则可能导致 OSS 签名不一致。
                    response = self.http.put(url, data=source, timeout=120)
                response.raise_for_status()
                return
            except (requests.ConnectionError, requests.Timeout):
                if attempt >= max_attempts:
                    raise
            except requests.HTTPError as exc:
                status = exc.response.status_code if exc.response is not None else None
                if status is None or status < 500 or attempt >= max_attempts:
                    if status == 403:
                        raise RuntimeError("预签名 URL 已失效,请重新执行上传以获取新 URL") from exc
                    raise
            time.sleep(2 ** (attempt - 1))

    def upload(
        self,
        knowledge_base_id: str,
        file_paths: Sequence[str | Path],
        meta_fields: Mapping[str, Any] | None = None,
    ) -> dict[str, Any]:
        docs = [LocalDocument.from_path(path) for path in file_paths]
        presign_docs = [
            models.GetKnowledgeBasePreSignedUrlRequestDocuments(
                path=doc.object_path,
                name=doc.path.name,
                size=doc.path.stat().st_size,
            )
            for doc in docs
        ]
        response = self.client.get_knowledge_base_pre_signed_url(
            knowledge_base_id,
            models.GetKnowledgeBasePreSignedUrlRequest(
                knowledge_base_id=knowledge_base_id,
                documents=presign_docs,
                expires_in=3600,
            ),
        )
        body = response.body
        self._check(body, "GetKnowledgeBasePreSignedUrl")

        urls = list(body.data.pre_signed_urls or [ ])

        if len(urls) != len(docs):
            raise RuntimeError("预签名 URL 数量与文件数量不一致")

        for doc, url in zip(docs, urls, strict=True):
            self._put_file(url, doc.path)

        add_docs = [
            models.AddDocumentsRequestDocuments(
                path=doc.object_path,
                name=doc.path.name,
                size=doc.path.stat().st_size,
            )
            for doc in docs
        ]
        response = self.client.add_documents(
            knowledge_base_id,
            models.AddDocumentsRequest(
                knowledge_base_id=knowledge_base_id,
                import_type="LOCAL_UPLOAD",
                documents=add_docs,
                meta_fields=dict(meta_fields) if meta_fields else None,
                dedup=models.AddDocumentsRequestDedup(
                    doc_name_dedup=True,
                    content_dedup=False,
                ),
            ),
        )
        body = response.body
        self._check(body, "AddDocuments")

        errors = list(getattr(body.data, "errors", None) or [ ])

        if errors:
            raise RuntimeError(f"文档注册失败:{errors}")
        return body.to_map()

    def search(
        self,
        knowledge_base_id: str,
        query: str,
        options: SearchOptions,
        image_url: str | None = None,
    ) -> dict[str, Any]:
        tag_filter = None
        if options.tag_conditions:
            tag_filter = models.SearchKnowledgeBaseRequestTagFilter(
                relation=options.tag_relation,
                conditions=[
                    models.SearchKnowledgeBaseRequestTagFilterConditions(
                        field=condition.field,
                        op=condition.op,
                        value=condition.value,
                    )
                    for condition in options.tag_conditions
                ],
            )
        response = self.client.search_knowledge_base(
            knowledge_base_id,
            models.SearchKnowledgeBaseRequest(
                query=query,
                version=options.version,
                page_number=1,
                page_size=options.page_size,
                rerank_model_name=options.rerank_model_name,
                tag_filter=tag_filter,
                image=(
                    models.SearchKnowledgeBaseRequestImage(url=image_url)
                    if image_url
                    else None
                ),
                retrieval_config=models.SearchKnowledgeBaseRequestRetrievalConfig(
                    candidate_count=options.candidate_count,
                    min_score=options.min_score,
                    semantic_weight=options.semantic_weight,
                    enable_query_expansion=options.enable_query_expansion,
                ),
            ),
        )
        body = response.body
        self._check(body, "SearchKnowledgeBase")
        return body.to_map()

upload.py

python 复制代码
"""批量上传本地文档;上传后请到控制台等待解析并发布版本。"""

from __future__ import annotations

import argparse
import json
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from kb_client import KnowledgeBaseClient


@dataclass(frozen=True)
class ManifestEntry:
    path: Path
    metadata: dict[str, Any]


def load_manifest(path: Path) -> list[ManifestEntry]:

    entries: list[ManifestEntry] = [ ]

    for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
        if not raw_line.strip():
            continue
        value = json.loads(raw_line)
        file_path = (path.parent / str(value["path"])).resolve()
        metadata = value.get("metadata") or {}
        if not isinstance(metadata, dict):
            raise ValueError(f"manifest 第 {line_number} 行 metadata 必须是对象")
        entries.append(ManifestEntry(file_path, metadata))
    return entries


def discover(paths: list[str], default_metadata: dict[str, Any]) -> list[ManifestEntry]:

    entries: list[ManifestEntry] = [ ]

    for value in paths:
        path = Path(value).expanduser()
        files = sorted(item for item in path.rglob("*") if item.is_file()) if path.is_dir() else [path]
        entries.extend(ManifestEntry(item.resolve(), default_metadata) for item in files)
    return entries


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("paths", nargs="*", help="文件或目录,可同时传多个")
    parser.add_argument("--config", default="config.json")
    parser.add_argument("--manifest", help="JSONL 文件;每行包含 path 和 metadata")
    args = parser.parse_args()

    config = json.loads(Path(args.config).read_text(encoding="utf-8"))
    aliyun = config["aliyun"]
    upload_config = config.get("upload") or {}
    default_metadata = upload_config.get("default_meta_fields") or {}
    entries = (
        load_manifest(Path(args.manifest).expanduser().resolve())
        if args.manifest
        else discover(args.paths, default_metadata)
    )
    if not entries:
        raise SystemExit("没有找到可上传文件")

    grouped: dict[str, list[ManifestEntry]] = defaultdict(list)
    for entry in entries:
        key = json.dumps(entry.metadata, ensure_ascii=False, sort_keys=True)
        grouped[key].append(entry)

    client = KnowledgeBaseClient(
        aliyun["access_key_id"], aliyun["access_key_secret"], aliyun["region_id"]
    )
    submitted = 0
    # AddDocuments 的 MetaFields 对整批生效,因此先按 metadata 分组再按 100 篇分批。
    for metadata_key, group in grouped.items():
        metadata = json.loads(metadata_key)
        for start in range(0, len(group), 100):
            batch = group[start : start + 100]
            result = client.upload(
                aliyun["knowledge_base_id"],
                [entry.path for entry in batch],
                meta_fields=metadata,
            )
            submitted += len(batch)
            print(json.dumps(result, ensure_ascii=False, indent=2))
            print(f"已提交 {submitted}/{len(entries)} 篇;metadata={metadata}")


if __name__ == "__main__":
    main()

app.py

python 复制代码
"""最小可运行的知识库检索 + OpenAI 兼容大模型问答页面。"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

import requests
from flask import Flask, jsonify, render_template, request

from kb_client import KnowledgeBaseClient, SearchOptions, TagCondition


CONFIG = json.loads(Path("config.json").read_text(encoding="utf-8"))
ALIYUN = CONFIG["aliyun"]
LLM = CONFIG.get("llm", {})
SCENARIO = CONFIG.get("scenario", {})
RETRIEVAL = CONFIG.get("retrieval", {})
KB = KnowledgeBaseClient(
    ALIYUN["access_key_id"], ALIYUN["access_key_secret"], ALIYUN["region_id"]
)
app = Flask(__name__)


def search_options() -> SearchOptions:

    raw_conditions = (RETRIEVAL.get("tag_filter") or {}).get("conditions") or [ ]

    return SearchOptions(
        version=ALIYUN.get("knowledge_base_version", "LATEST_PUBLISHED"),
        page_size=int(RETRIEVAL.get("page_size", 6)),
        candidate_count=int(RETRIEVAL.get("candidate_count", 48)),
        min_score=float(RETRIEVAL.get("min_score", 0.0)),
        semantic_weight=float(RETRIEVAL.get("semantic_weight", 0.5)),
        enable_query_expansion=bool(RETRIEVAL.get("enable_query_expansion", True)),
        rerank_model_name=str(RETRIEVAL.get("rerank_model_name") or "") or None,
        tag_relation=str((RETRIEVAL.get("tag_filter") or {}).get("relation", "and")),
        tag_conditions=tuple(
            TagCondition(str(item["field"]), str(item["op"]), item.get("value"))
            for item in raw_conditions
        ),
    )


def find_results(payload: Any) -> list[dict[str, Any]]:
    """兼容 SDK 响应字段大小写,将检索切片提取为列表。"""
    if isinstance(payload, list):
        return [item for item in payload if isinstance(item, dict)]
    if not isinstance(payload, dict):

        return [ ]

    for key in ("Results", "results", "Chunks", "chunks", "Items", "items"):
        if isinstance(payload.get(key), list):
            return payload[key]
    for key in ("Data", "data"):
        found = find_results(payload.get(key))
        if found:
            return found

    return [ ]



def field(item: dict[str, Any], *names: str) -> Any:
    for name in names:
        if item.get(name) not in (None, ""):
            return item[name]
    return ""


def llm_answer(question: str, results: list[dict[str, Any]]) -> str:
    if not results:
        return "现有知识库未检索到相关资料,请转人工确认或补充资料后重试。"
    if not LLM.get("enabled", True):
        return "大模型未启用;请查看下方检索结果。"
    context = "\n\n".join(
        f"[来源{index}] {field(item, 'DocumentName', 'documentName', 'doc_name')}\n"
        f"{field(item, 'Content', 'content', 'Text', 'text')}"
        for index, item in enumerate(results, 1)
    )
    url = str(LLM["base_url"]).rstrip("/") + "/chat/completions"
    response = requests.post(
        url,
        headers={"Authorization": f"Bearer {LLM['api_key']}"},
        json={
            "model": LLM["model"],
            "temperature": 0.1,
            "messages": [
                {"role": "system", "content": SCENARIO.get("system_prompt", "只依据资料回答。")},
                {"role": "user", "content": f"问题:{question}\n\n检索资料:\n{context}"},
            ],
        },
        timeout=90,
    )
    response.raise_for_status()
    return response.json()["choices"][0]["message"]["content"].strip()


@app.get("/")
def index():
    return render_template(
        "index.html",
        title=SCENARIO.get("title", "知识库问答 Demo"),

        sample_questions=SCENARIO.get("sample_questions", [ ]),

        image_enabled=bool(SCENARIO.get("image_enabled", False)),
    )


@app.post("/api/ask")
def ask():
    payload = request.get_json(silent=True) or {}
    question = str(payload.get("question", "")).strip()
    image_url = str(payload.get("image_url", "")).strip() or None
    if not question:
        return jsonify({"error": "问题不能为空"}), 400
    try:
        raw = KB.search(
            ALIYUN["knowledge_base_id"],
            question,
            options=search_options(),
            image_url=image_url,
        )
        results = find_results(raw)
        return jsonify({"answer": llm_answer(question, results), "sources": results})
    except Exception as exc:
        return jsonify({"error": str(exc)}), 500


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=7860, debug=False)

templates/index.html

html 复制代码
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>{{ title }}</title>
  <style>
    body{margin:0;background:#f6f7fb;color:#1f2937;font:15px system-ui,sans-serif}
    main{max-width:860px;margin:0 auto;padding:42px 18px}.card{background:#fff;border:1px solid #e5e7eb;border-radius:18px;padding:24px;box-shadow:0 8px 30px #1111}
    textarea{box-sizing:border-box;width:100%;min-height:100px;border:1px solid #d1d5db;border-radius:12px;padding:14px;font:inherit;resize:vertical}
    button{margin-top:12px;border:0;border-radius:10px;padding:11px 18px;background:#4f46e5;color:#fff;cursor:pointer}.chip{background:#eef2ff;color:#3730a3;margin:4px;padding:7px 10px}
    pre{white-space:pre-wrap;line-height:1.65}.muted{color:#6b7280}.source{border-top:1px solid #eee;padding:12px 0}
  </style>
</head>
<body><main><h1>{{ title }}</h1><p class="muted">回答由已发布知识库内容生成,并展示检索依据。</p>
  <div>{% for q in sample_questions %}<button class="chip" onclick='setQ({{ q|tojson }})'>{{ q }}</button>{% endfor %}</div>
  <section class="card"><textarea id="q" placeholder="输入问题"></textarea>{% if image_enabled %}<input id="image" style="box-sizing:border-box;width:100%;margin-top:8px;padding:10px;border:1px solid #d1d5db;border-radius:10px" placeholder="可选:题目图片 URL">{% endif %}<button id="ask" onclick="ask()">发送</button><pre id="answer"></pre><div id="sources"></div></section>
</main><script>
const q=document.querySelector('#q'), answer=document.querySelector('#answer'), sources=document.querySelector('#sources');
function setQ(value){q.value=value;q.focus()}
async function ask(){const text=q.value.trim();if(!text)return;answer.textContent='检索与生成中...';sources.innerHTML='';
  const image=document.querySelector('#image');
  const res=await fetch('/api/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:text,image_url:image?image.value.trim():''})});
  const data=await res.json();answer.textContent=data.answer||('Error: '+data.error);

  for(const [i,item] of (data.sources||[ ]).entries()){const div=document.createElement('div');div.className='source';div.textContent=`来源 ${i+1}:${item.DocumentName||item.documentName||''}\n${item.Content||item.content||item.Text||item.text||''}`;sources.appendChild(div)}

}
</script></body></html>

start.sh

bash 复制代码
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
test -f config.json || { cp config.example.json config.json; echo "请先填写 config.json"; exit 1; }
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements.txt
python app.py

启动与验证

bash 复制代码
mkdir -p kb-demo/documents kb-demo/templates
cd kb-demo
cp config.example.json config.json
# 本机编辑 config.json,填入配置;不要提交该文件
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements.txt
python upload.py --manifest documents.jsonl

上传接口返回成功仅表示已提交异步解析。到控制台确认处理完成后再发布版本。config.json 使用 LATEST_PUBLISHED 会检索最新已发布版本,也可锁定明确版本;锁定版本被删除后查询会返回 404。当前默认同时最多保留 3 个已发布版本,实际额度以控制台为准。

发布后启动:

bash 复制代码
chmod +x start.sh
./start.sh

另开终端验证:

bash 复制代码
curl -sS http://127.0.0.1:7860/api/ask \
  -H 'Content-Type: application/json' \
  -d '{"question":"商品通常在付款后多久发货?"}'

页面地址:http://127.0.0.1:7860

推荐测试问题

  • 商品通常在付款后多久发货?

  • 超过保修期后还能维修吗?

  • 申请退货需要满足哪些条件?

验收标准

  • 页面能正常打开,问题提交后同时返回答案和检索来源。

  • 回答中的 [来源N] 能对应到下方文档切片。

  • 问一个资料没有覆盖的问题,回答应明确说资料不足,而不是补写事实。

  • 替换或补充资料后,在控制台重新发布版本,页面可检索到新内容。

本场景注意事项

  • 优先上传正式生效的政策,避免同时保留互相冲突的旧版本。

  • 用真实客户问法测试,不要只用手册标题做查询。

  • 正式上线前抽查答案引用是否支持结论。

常见问题

  • 返回"版本不存在":先在控制台发布版本,或把配置改成真实存在的版本名。

  • 上传后暂时搜不到:上传和解析是异步的,需等待控制台显示完成并重新发布版本。

  • 401/403:检查 AK/SK 是否有效、RAM 权限是否包含知识库 OpenAPI 调用权限。

  • 大模型调用失败:先把 llm.enabled 改为 false 验证纯检索链路,再检查大模型的 base_url、Token 和模型名。

  • 线上部署:不要继续使用 Flask 开发服务器;应改用生产 WSGI、密钥管理、鉴权、审计和限流。

图片查询说明:image 只用于 OCR/IMAGE2TEXT 后辅助检索,不会自动把用户原图传给最终大模型。URL 必须能被服务端访问且不能指向内网或保留网段;知识库内对象优先使用 objectKey,base64 只建议用于小图。

申请免费邀测

阿里云 Milvus 知识库现已在 北京、杭州、深圳等地域开启免费邀测。欢迎加入"向量检索 Milvus 知识库用户交流群"群的钉钉群号: 171685038915,申请免费试用名额,抢先体验企业级知识库问答能力。

相关推荐
hyunbar7771 小时前
LangChain 实战:Agent 4类结构化输出方式
人工智能
咖啡星人k1 小时前
2026 可观测性实战:把观测契约写进SPEC,MonkeyCode 云端跑通
人工智能·机器学习
RAOY的AI笔记1 小时前
GPT-6 Astra开发指南:API调用、工具使用与AI Agent应用思路
大数据·人工智能·gpt
Code_Artist1 小时前
从 Tool Calling 到能力编排:重新理解 Agent Skill 的运行机制——以 tRPC-Agent-Go 为例
人工智能·openai·agent
geneculture1 小时前
融智学视域下的心灵哲学范式重审--行为主义、功能主义与中文屋论证的融智学对照分析 (高级科普 · 学术论文)
人工智能·信息科学·融智学的重要应用·哲学与科学统一性·融智时代(杂志)·心智哲学重审·中文屋论题
chuntian_tester2 小时前
AI自动化第3步【用例设计】
人工智能·测试工具·ai·自动化
科技每日热闻2 小时前
中国企业出海开展业务,如何挑选可安全合规使用国际大模型的云平台?Amazon Bedrock 在同一平台完成国际模型接入、区域选择与合规治理
大数据·人工智能·安全·ai
xiongmosy2 小时前
从“移动的家”到“可居住的空间”:小米澎程正在重新定义“车”能做什么
人工智能
Geek-Chow2 小时前
MCP 模型上下文协议:十二、自测、练习与源码入口
人工智能