Todos os produtos
Search
Central de documentação

Vector Retrieval Service for Milvus:Crie uma aplicação de perguntas e respostas para atendimento ao cliente inteligente usando a base de conhecimento do Alibaba Cloud Milvus

Última atualização: Sep 02, 2026

Neste tutorial, você utiliza a base de conhecimento do Alibaba Cloud Milvus para construir um índice de conhecimento tagueado a partir de documentos de atendimento ao cliente. O corpus abrange manuais de product, FAQs e políticas de trocas e devoluções, garantias e notas fiscais. Ao final, você terá uma página de perguntas e respostas para atendimento ao cliente que recupera respostas por meio de filtragem por tags e modelo de rerank, gera respostas via LLM e exibe as source das informações.

Visão geral da solução

O pipeline completo consiste em: definir tags e crie uma base de conhecimento no console → importar documentos de atendimento ao cliente em lote por tag → publicar uma versão → pesquisar via SDK, opcionalmente filtrando por tag → passar os resultados para um LLM gerar respostas com citações de source → servir a página de perguntas e respostas com Flask.

Este tópico foca em três aspectos específicos de cenários de atendimento ao cliente: filtragem por tag (metadados), o modelo de rerank e a rastreabilidade das respostas. Para o pipeline básico de base de conhecimento (upload pré-assinado, registro de dados, publicação de versão) e a implementação mais simples de perguntas e respostas, consulte Build a personal knowledge Q&A application.

Antes de começar, prepare de 5 a 20 documentos nos formatos PDF, DOCX, Markdown ou TXT. Todo o processo leva cerca de 20 a 30 minutos. O tempo de análise dos documentos depende da quantidade e do tamanho dos arquivos.

Pré-requisitos

  • Uma base de conhecimento criada em uma região compatível: China (Hangzhou), China (Beijing), China (Zhangjiakou) ou China (Shenzhen). Anote o ID da base de conhecimento, que segue o formato kd-803ae9b10cc31.

  • Um usuário ram criado na sua conta Alibaba Cloud, com a opção Use Permanent AccessKey selecionada e a política de sistema AliyunMilvusFullAccess anexada ao usuário ram.

  • Um endpoint de LLM compatível com o protocolo OpenAI chat/completions, juntamente com uma chave de API. Por exemplo, utilize o endpoint compatível com OpenAI do Alibaba Cloud Model Studio.

  • Python 3.8 ou superior instalado na sua máquina local. Para orientações sobre segurança de credenciais e implantação em produção, consulte Pre-launch checklist.

Etapa 1: Definir tags

As tags associam dimensões de negócio (como tipo de documento, linha de product e data de vigência) aos documentos. Elas são gravadas na base de conhecimento durante a importação e usadas para filtragem durante a pesquisa. São fundamentais para distinguir políticas, manuais e FAQs em cenários de atendimento ao cliente.

  1. Acesse o console do Alibaba Cloud Milvus e abra a página de detalhes da base de conhecimento desejada.

  2. Em Basic Information, localize Tags e clique em gerencie.

  3. Na caixa de diálogo Tag Management, insira um nome de tag, selecione um tipo de campo e clique em Add. Este tutorial utiliza as três tags a seguir, todas do tipo string:

    Nome da tag

    Descrição

    Valor de exemplo

    docType

    Tipo de documento

    policy, manual, faq

    productLine

    Linha de product aplicável

    all, phone

    effectiveDate

    Data de vigência

    2026-01-01

  4. Após adicionar as três tags, clique em Done. O campo Tags na página de detalhes exibirá "3 tags". Neste tutorial, os valores das tags são gravados nos documentos em massa durante a importação na Etapa 5, portanto não é necessário defina valores no console agora. Para taguear documentos que já existem na base de conhecimento, use Set Tags na página Data Management, conforme descrito em FAQ.

Observações de uso

  • Tipos de campo — O tipo de campo pode ser string, int64, list, float32 ou bool.

  • Bases de conhecimento existentes — É possível adicionar tags a uma base de conhecimento existente posteriormente, sem precisar reconstruí-la.

  • Definições não são pré-requisito para gravar valores — Definir tags no console não é obrigatório para gravar valores de tags. Campos indefinidos ainda podem ser gravados junto com os documentos e usados para filtragem. Além disso, exclua uma definição de tag não apaga valores históricos já gravados. A definição não funciona como uma declaração independente de campo de índice. Na prática, as definições servem para exibição no console, gerenciamento de opções para tags do tipo lista e conversão de tipos de valor durante a filtragem por string, int64, float32, bool ou list. A prática recomendada é definir as tags primeiro para obter validação de tipo e maior gerenciabilidade, em vez de tratar isso como um pré-requisito obrigatório.

  • Alterações nas definições não reconstroem dados — A caixa de diálogo avisa que alterações nas tags afetam a construção do índice, mas adicionar ou remover definições de tags não reconstrói nem migra dados já importados, e os valores históricos das tags não são apagados. Ainda assim, finalize as definições de tags antes da importação em lote para garantir que a exibição no console, o gerenciamento de opções e a conversão de tipos de valor permaneçam consistentes desde o início.

Importante

A coluna Tag Options predefine valores permitidos para uma tag e afeta apenas como os valores são inseridos no console: quando deixada em branco, a filtragem por essa tag no console exige digitação manual dos valores; após insira valores separados por vírgula (por exemplo, after-sales,logistics,billing), o console passa a usar uma lista suspensa, evitando erros de digitação. Essa configuração não valida valores gravados via API — usar AddDocuments para passar valores fora das opções definidas ainda será bem-sucedido, e a garantia do intervalo de valores deve ser feita pelo próprio manifesto de importação.

Etapa 2: Preparar documentos e o manifesto de importação

  • Coloque os documentos de atendimento ao cliente no diretório local documents/. Por exemplo:

kb-demo/
├── documents/
│   ├── shipping-policy.md
│   ├── return-policy.md
│   ├── warranty-policy.md
│   ├── phone-manual.md
│   └── invoice-faq.md
  • Crie o arquivo documents.jsonl. Cada linha descreve um documento e suas tags. Os nomes das tags devem corresponder aos definidos na Etapa 1.

{"path": "documents/shipping-policy.md", "metadata": {"docType": "policy", "productLine": "all", "effectiveDate": "2026-01-01"}}
{"path": "documents/return-policy.md", "metadata": {"docType": "policy", "productLine": "all", "effectiveDate": "2026-01-01"}}
{"path": "documents/warranty-policy.md", "metadata": {"docType": "policy", "productLine": "phone", "effectiveDate": "2026-03-01"}}
{"path": "documents/phone-manual.md", "metadata": {"docType": "manual", "productLine": "phone", "effectiveDate": "2026-03-01"}}
{"path": "documents/invoice-faq.md", "metadata": {"docType": "faq", "productLine": "all", "effectiveDate": "2026-02-01"}}

Os nomes dos arquivos devem refletir os tópicos dos documentos, e o conteúdo deve conter títulos completos e contexto adequado para que a recuperação consiga corresponder a perguntas reais, incluindo as perguntas de exemplo configuradas na Etapa 3. Dê preferência à importação de políticas oficialmente vigentes e evite manter versões antigas conflitantes simultaneamente.

Etapa 3: configure parâmetros de execução

  • Crie o diretório do projeto e instale as dependências.

mkdir -p kb-demo/documents kb-demo/templates
cd kb-demo
python3 -m venv .venv
source .venv/bin/activate
pip install Flask==3.1.1 requests==2.32.4 alibabacloud-milvusknowledgebase20260604==1.0.0

Os pacotes acima instalam dependências adicionais automaticamente, como alibabacloud_tea_openapi (importado por kb_client.py) e Jinja2 (usado pelo Flask para renderização de templates). Não é necessário instalá-los separadamente.

  • Crie o arquivo config.json e preencha com suas próprias credenciais e informações da base de conhecimento.

{
  "aliyun": {
    "access_key_id": "YOUR_ACCESS_KEY_ID",
    "access_key_secret": "YOUR_ACCESS_KEY_SECRET",
    "region_id": "cn-hangzhou",
    "knowledge_base_id": "kd-xxxxxxxxxxxxx",
    "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": "Intelligent Customer Service Knowledge Base Q&A",
    "system_prompt": "You are a corporate customer service assistant. Answer based only on the retrieved documents, in a clear and friendly customer service tone; cite [Source N] for key conclusions. If the retrieved documents do not directly cover the user's question, you must answer 'The available documents do not explicitly cover this.' Do not infer conclusions from partially relevant documents, and do not add contact methods or service channels beyond the documents.",
    "image_enabled": false,
    "sample_questions": [
      "How long after payment does it usually take to ship the product?",
      "Can the product still be repaired after the warranty expires?",
      "What conditions must be met to request a return?"
    ]
  }
}

Considerações sobre os valores dos parâmetros de recuperação neste cenário:

  • min_score=0.35: reduz a entrada de conteúdo com baixa relevância nas respostas.

  • semantic_weight=0.7: prioriza a busca semântica para abranger perguntas coloquiais típicas de atendimento ao cliente.

  • rerank_model_name: reclassifica os trechos candidatos para melhorar a precisão do principal resultado.

  • tag_filter.conditions: deixar em branco significa que não há filtragem. Para filtragem por tags, consulte a Etapa 7.

Etapa 4: Escrever o cliente da base de conhecimento

Salve o conteúdo abaixo como kb_client.py. Ele encapsula o upload com tags e a pesquisa com filtragem.

"""Milvus knowledge base OpenAPI SDK: local upload with tags and search against a published version."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
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:
        # Successful responses do not return the code field (its value is None), so treat only an explicit non-zero code as a failure.
        if body is None:
            raise RuntimeError(f"{action} returned an empty response")
        if getattr(body, "success", None) is False or (
            getattr(body, "code", None) not in (None, 0, "0")
        ):
            raise RuntimeError(
                f"{action} failed: {getattr(body, 'message', 'unknown')}; "
                f"requestId={getattr(body, 'request_id', '')}"
            )

    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("The number of pre-signed URLs does not match the number of files")

        for doc, url in zip(docs, urls, strict=True):
            with doc.path.open("rb") as source:
                # The pre-signed URL is signed with an empty Content-Type; do not include Content-Type in the request.
                self.http.put(url, data=source, timeout=120).raise_for_status()

        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"Failed to register data: {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()

Etapa 5: Escrever o script de upload em lote

Salve o conteúdo abaixo como upload.py. O campo MetaFields do método AddDocuments aplica-se a todo o lote, então o script primeiro agrupa os documentos por tag e depois os envia em lotes de 100.

"""Batch upload local documents by tag; after upload, wait for parsing in the console and publish a version."""

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"metadata on line {line_number} of the manifest must be an object")
        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="Files or directories; you can pass multiple")
    parser.add_argument("--config", default="config.json")
    parser.add_argument("--manifest", help="JSONL file; each line contains path and 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("No files found to upload")

    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
    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]
            client.upload(
                aliyun["knowledge_base_id"],
                [entry.path for entry in batch],
                meta_fields=metadata,
            )
            submitted += len(batch)
            print(f"Submitted {submitted}/{len(entries)} documents; metadata={metadata}")

if __name__ == "__main__":
    main()

execute o upload:

python3 upload.py --manifest documents.jsonl

A saída exibe os lotes agrupados por tag. Por exemplo, 5 documentos com 4 combinações de tags geram 4 lotes:

Submitted 2/5 documents; metadata={'docType': 'policy', 'effectiveDate': '2026-01-01', 'productLine': 'all'}
Submitted 3/5 documents; metadata={'docType': 'policy', 'effectiveDate': '2026-03-01', 'productLine': 'phone'}
Submitted 4/5 documents; metadata={'docType': 'manual', 'effectiveDate': '2026-03-01', 'productLine': 'phone'}
Submitted 5/5 documents; metadata={'docType': 'faq', 'effectiveDate': '2026-02-01', 'productLine': 'all'}
Importante

Um retorno bem-sucedido da API de upload significa apenas que a análise assíncrona foi enviada. Acesse a página Data Management no console e confirme se o status do documento mudou para Processing Complete antes de prosseguir para a próxima etapa. A coluna de tags mostra as tags gravadas, como productLine=all, docType=faq +1.

Etapa 6: Publicar uma versão

Após a conclusão da análise, os documentos permanecem não publicados e só podem ser pesquisados depois que você publicar uma versão. Atualmente, essa operação é suportada apenas no console.

  1. Abra a página de detalhes da base de conhecimento, clique em na aba Version Management, confirme se há alterações pendentes e clique em Publish Version.

  2. Na etapa Confirm Changes, revise o log de alterações (dados recém-adicionados são listados individualmente) e clique em Next.

  3. Na etapa Enter Description, insira uma nota de lançamento (até 200 caracteres) e clique em Publish.

  4. Em Version History, confirme se o status da nova versão é Published e anote o número da versão. A primeira publicação é v1, seguida por v2 e v3. Quando knowledge_base_version no arquivo config.json usa LATEST_PUBLISHED, a versão publicada mais recente é pesquisada automaticamente. Você também pode especifique um número de versão explícito. Não use DRAFT para fornecer um service estável de perguntas e respostas.

Cotas de versão e exclusão

  • Cota padrão — Por padrão, podem existir no máximo 3 versões publicadas simultaneamente.

  • Escopo da cota — Esse limite é calculado por tenant e não aumenta com a especificação de CU da instância.

  • Aumento de cota — Para elevar o limite, envie um ticket para avaliação. Atualmente, não há uma entrada de autoatendimento para solicitação de cota pelos usuários.

  • Comportamento no limite — Ao atingir o limite, o botão Publish Version fica acinzentado, enquanto a página ainda mostra "There are N pending changes to publish". exclua versões antigas que não são mais necessárias em Version History antes de publicar novamente.

  • Atualizações frequentes — Se os documentos forem atualizados frequentemente, mantenha apenas a versão vigente atual mais a versão histórica mais recente.

Aviso

A exclusão de versão é irreversível. Após a exclusão, a versão torna-se imediatamente indisponível para pesquisa (o backend limpa os dados de forma assíncrona). Antes de exclua, certifique-se de que nenhuma aplicação esteja fixada nesse número de versão e migre as aplicações que ainda a utilizam para uma nova versão.

Etapa 7: Pesquisar com filtragem por tags

configure condições de filtro em retrieval.tag_filter.conditions no arquivo config.json para restringir o escopo da pesquisa a tags específicas. Por exemplo, para pesquisar apenas documentos de políticas:

{
  "tag_filter": {
    "relation": "and",
    "conditions": [
      {"field": "docType", "op": "=", "value": "policy"}
    ]
  }
}

O parâmetro relation suporta and (todas as condições atendidas) e or (qualquer condição atendida). O parâmetro op suporta os seguintes operadores:

Operador

Comportamento observado

=

Efetivo. Correspondência exata (para tags int64, funciona tanto com número quanto com string)

in, not in

Efetivo. Verifica se o valor pertence ou não ao conjunto fornecido

, >, <, , , empty, not empty, start with, end with

Inefetivo. A condição é ignorada, todos os dados são retornados e nenhum erro é reportado

contains, not contains

Não recomendado. São aliases para in/not in, não verificam contenção de string

Os únicos operadores que realmente funcionam são =, in e not in. Os outros operadores aparecem na lista de "Supported operators" do erro 400 Unsupported tag filter operator, mas testes mostram que, ao serem utilizados, a condição é silenciosamente ignorada e todos os dados não filtrados são retornados. Além disso, contains e not contains são apenas aliases para in/not in, não verificam contenção de string. Passar um fragmento de string retorna 0 resultados.

Para contornar essas limitações:

  • Para filtrar por intervalos numéricos ou de datas, use in com valores enumerados.

  • Para verifique se uma tag está vazia, use = "".

  • Após configure condições de filtro, sempre compare a contagem total de resultados com o resultado não filtrado para confirme se o filtro teve efeito. Além disso, se field estiver definido com um nome de tag indefinido, a API igualmente não reporta erro e simplesmente retorna 0 resultados. Definir op com aliases como eq, ==, equal ou like retorna 400 Unsupported tag filter operator.

Usando o corpus deste tópico como exemplo, veja o escopo da pesquisa sob diferentes condições de filtro:

Condição de filtro

Documentos correspondentes

Nenhuma condição definida

Todos os documentos

docType = policy

Políticas de envio, trocas e devoluções e garantia

docType = faq

FAQ sobre notas fiscais

docType = policy e productLine = phone

Política de garantia

docType = faq ou docType = manual (relation é or)

FAQ sobre notas fiscais, manual do telefone

effectiveDate = 2026-01-01

As duas políticas vigentes nessa data

Etapa 8: Escrever o service de perguntas e respostas

Salve o conteúdo abaixo como app.py.

"""Knowledge base retrieval + OpenAI-compatible LLM Q&A service."""

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]]:
    """Extract the list of retrieved chunks from the response."""
    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"):
        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 LLM.get("enabled", True):
        return "The LLM is disabled. See the retrieval results below."
    if not results:
        return "The available documents do not explicitly cover this."
    context = "\n\n".join(
        f"[Source {index}] {field(item, 'documentName', 'DocumentName')}\n"
        f"{field(item, 'content', 'Content')}"
        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", "Answer based only on the documents.")},
                {"role": "user", "content": f"Question: {question}\n\nRetrieved documents:\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", "Knowledge Base Q&A"),
        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": "The question cannot be empty"}), 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)

Salve o conteúdo abaixo como templates/index.html.

<!doctype html>
<html lang="en">
<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}
    textarea{box-sizing:border-box;width:100%;min-height:100px;border:1px solid #d1d5db;border-radius:12px;padding:14px;font:inherit}
    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">Answers are generated from the published knowledge base content, with retrieval sources displayed.</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="Enter your question"></textarea>
    <button id="ask" onclick="ask()">Send</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='Retrieving and generating...'; sources.innerHTML='';
  const res=await fetch('/api/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:text})});
  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=`Source ${i+1}: ${item.documentName||''}\n${item.content||''}`;
    sources.appendChild(div);
  }
}
</script></body></html>
Importante

O atributo onclick dos botões de perguntas de exemplo deve ser envolvido em aspas simples (onclick='setQ({{ q|tojson }})'). Se você usar aspas duplas, as aspas duplas do JSON produzidas por tojson fecharão o atributo HTML prematuramente, e os botões deixarão de responder aos cliques.

Etapa 9: Iniciar e verifique

  • Inicie o service.

python3 app.py
  • Abra http://127.0.0.1:7860 em um navegador, clique em uma pergunta de exemplo ou digite uma manualmente e clique em Send.

  • Você também pode chamar a API diretamente para verificação.

curl -sS http://127.0.0.1:7860/api/ask \
  -H 'Content-Type: application/json' \
  -d '{"question":"How long after payment does it usually take to ship the product?"}'

verifique os quatro critérios de aceitação a seguir:

  • A página abre normalmente e o envio de uma pergunta retorna tanto a resposta quanto as source de recuperação.

  • Cada referência [Source N] na resposta possui um trecho de documento correspondente na área de source abaixo.

  • Quando uma pergunta aborda conteúdo não presente nos documentos, a resposta é "The available documents do not explicitly cover this", em vez de fatos inventados.

  • Após adicionar documentos e publicar uma versão novamente no console, a página consegue pesquisar o novo conteúdo.

Recomendações de ajuste

  • Ajuste o tamanho do trecho conforme o formato do documento: documentos de atendimento ao cliente são majoritariamente entradas curtas de FAQ e cláusulas de políticas. Em Processing Policies na página de detalhes da base de conhecimento, clique em crie Policy e escolha Smart Splitting ou Split by Length como método de segmentação. Note que o comprimento máximo do segmento é medido em caracteres (padrão de 512 caracteres). Para cenários de entradas curtas, defina entre 380 e 580 caracteres (aproximadamente 256 a 384 tokens). Após crie a política, especifique-a durante a importação através de AddDocumentsRequest.strategy_id. O script upload.py deste tutorial não define esse campo; para usar uma política de processamento personalizada, adicione o campo à requisição AddDocuments.

  • **Ajuste o reranking e o min_score em conjunto**: após ative rerank_model_name, a pontuação de rerank e a pontuação de similaridade vetorial não estão na mesma escala. Manter o min_score original pode eliminar poucos resultados (em testes, três perguntas de atendimento ao cliente mantiveram apenas um resultado cada após a ativação do reranking). Se uma pergunta exigir uma resposta combinada de vários documentos, reduza adequadamente o min_score ao ative o reranking.

  • Atenção às conclusões incorretas causadas por documentos parcialmente relevantes: quando uma pergunta está literalmente relacionada a um documento, mas não é coberta semanticamente (por exemplo, o documento descreve apenas prazos de envio enquanto o usuário pergunta se pagamento na entrega é suportado), o LLM pode tirar uma conclusão errada a partir dele. No system_prompt, exija explicitamente que o modelo responda que não sabe quando os documentos não cobrirem diretamente a pergunta e proíba inferências a partir de documentos parcialmente relevantes. Faça verificações aleatórias com perguntas reais de clientes antes do lançamento.

  • Teste com perguntas reais de clientes, não apenas com títulos de manuais. Um semantic_weight mais alto (como 0.7) cobre melhor expressões coloquiais.

  • Prefira importar políticas oficialmente vigentes e evite manter versões antigas conflitantes simultaneamente. Após atualizações de políticas, publique uma versão novamente e distinga as versões com a tag effectiveDate.

Entendendo as pontuações de pesquisa

Cada resultado de pesquisa também retorna scoreDetails, por exemplo {"keywordScore": 0.368, "semanticScore": 0.819}, o que ajuda a julgar se a correspondência veio principalmente de palavras-chave ou de busca semântica.

As três pontuações significam o seguinte:

  • keywordScore é a similaridade por palavras-chave.

  • semanticScore é a similaridade vetorial quando o reranking está desativado, e a pontuação do modelo de rerank quando o reranking está ativado.

  • score é a pontuação final de classificação e filtragem obtida pela ponderação das duas anteriores, calculada como score ≈ (1-semantic_weight) × keywordScore + semantic_weight × semanticScore (um rank feature também pode ser adicionado). O min_score filtra com base nessa pontuação final. A escala de pontuação muda entre modelos, com ou sem reranking e com diferentes pesos. As pontuações não podem ser comparadas entre configurações diferentes e não existe um limiar universalmente recomendado: defina inicialmente o min_score como 0, recupere um lote de resultados e rotule manualmente sua relevância, escolhendo então um limiar baseado na distribuição de recalls e falsos positivos. Recalibre sempre que ajustar o semantic_weight ou alternar o reranking.

FAQ

Sintoma

Causa e solução

A pesquisa retorna 400 Unsupported tag filter operator

O parâmetro op usa aliases como eq, == ou like. Use =, in ou not in.

A filtragem por tags foi adicionada, mas a contagem de resultados é exatamente a mesma sem filtragem

Um operador inefetivo (como >, , ou empty) está sendo usado. Apenas =, in e not in têm efeito.

A filtragem por tags sempre retorna 0 resultados

O campo field está definido com um nome de tag indefinido (a API não reporta erro). verifique a ortografia do nome da tag na página de detalhes da base de conhecimento → Tags → gerencie; confirme também se as tags foram realmente gravadas para esses documentos durante a importação. Definir tags no console não é pré-requisito para gravar valores de tags. Para detalhes, consulte Usage notes na Etapa 1.

O botão Publish Version está acinzentado, mas a página mostra alterações pendentes

O limite de 3 versões publicadas foi atingido (passe o mouse sobre o botão para ver a dica). exclua versões antigas em Version History e publique novamente.

A pesquisa retorna 404 Knowledge base version ... does not exist

Nenhuma versão foi publicada ou knowledge_base_version não corresponde ao número real da versão. Publique uma versão no console primeiro.

O upload é bem-sucedido, mas nada é pesquisável

O upload e a análise são assíncronos. Aguarde até que a página Data Management no console mostre Processing Complete e publique uma versão novamente.

Chamadas retornam 401 ou 403

O AccessKey é inválido ou o usuário ram não possui a permissão AliyunMilvusFullAccess.

Falhas ocasionais de conexão ao fazer PUT no OSS durante o upload

Instabilidade de rede. Simplesmente tente novamente o upload desse arquivo.

Documentos históricos não possuem tags e não são encontrados pela filtragem por tags

As tags são gravadas durante a importação; documentos importados antes das definições de tags não são correspondidos pela filtragem. Você pode usar Set Tags para documentos individuais na página Data Management ou reimportá-los.

Checklist pré-lançamento

  • Armazenamento de credenciais — Armazene o par de AccessKey e a chave de API do LLM apenas no arquivo local config.json. Não faça commit deles em repositórios de código nem os compartilhe em grupos de chat. Após a demonstração, rotacione ou exclua prontamente as credenciais temporárias.

  • Privilégio mínimo — Utilize um usuário ram dedicado com privilégio mínimo que possa ser rotacionado. Não use o AccessKey da conta Alibaba Cloud a longo prazo.

  • Conteúdo autorizado — Importe apenas documentos que você tem autorização para manipular. Toda resposta deve ser rastreável até um trecho de source.

  • Implantação em produção — Para implantações externas, não continue usando o servidor de desenvolvimento do Flask. Utilize um servidor WSGI de produção e adicione autenticação, HTTPS, mascaramento de logs de acesso, limitação de taxa e auditoria.

  • Alternativa humana — Para perguntas e respostas críticas sobre políticas, mantenha uma alternativa de atendimento humano.