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
AliyunMilvusFullAccessanexada 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.
Acesse o console do Alibaba Cloud Milvus e abra a página de detalhes da base de conhecimento desejada.
Em Basic Information, localize Tags e clique em gerencie.
-
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
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,float32oubool.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,booloulist. 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.
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.jsone 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'}
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.
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.
Na etapa Confirm Changes, revise o log de alterações (dados recém-adicionados são listados individualmente) e clique em Next.
Na etapa Enter Description, insira uma nota de lançamento (até 200 caracteres) e clique em Publish.
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 porv2ev3. Quandoknowledge_base_versionno arquivoconfig.jsonusaLATEST_PUBLISHED, a versão publicada mais recente é pesquisada automaticamente. Você também pode especifique um número de versão explícito. Não useDRAFTpara 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.
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 |
|
|
Efetivo. Verifica se o valor pertence ou não ao conjunto fornecido |
|
|
Inefetivo. A condição é ignorada, todos os dados são retornados e nenhum erro é reportado |
|
|
Não recomendado. São aliases para |
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
incom 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
fieldestiver definido com um nome de tag indefinido, a API igualmente não reporta erro e simplesmente retorna 0 resultados. Definiropcom aliases comoeq,==,equaloulikeretorna400 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 |
|
|
Políticas de envio, trocas e devoluções e garantia |
|
|
FAQ sobre notas fiscais |
|
|
Política de garantia |
|
|
FAQ sobre notas fiscais, manual do telefone |
|
|
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>
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:7860em 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 scriptupload.pydeste tutorial não define esse campo; para usar uma política de processamento personalizada, adicione o campo à requisiçãoAddDocuments.**Ajuste o reranking e o
min_scoreem conjunto**: após ativererank_model_name, a pontuação de rerank e a pontuação de similaridade vetorial não estão na mesma escala. Manter omin_scoreoriginal 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 omin_scoreao 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_weightmais 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 comoscore ≈ (1-semantic_weight) × keywordScore + semantic_weight × semanticScore(um rank feature também pode ser adicionado). Omin_scorefiltra 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 omin_scorecomo 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 osemantic_weightou alternar o reranking.
FAQ
|
Sintoma |
Causa e solução |
|
A pesquisa retorna |
O parâmetro |
|
A filtragem por tags foi adicionada, mas a contagem de resultados é exatamente a mesma sem filtragem |
Um operador inefetivo (como |
|
A filtragem por tags sempre retorna 0 resultados |
O campo |
|
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 |
Nenhuma versão foi publicada ou |
|
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 |
|
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.