Dalam tutorial ini, Anda akan menggunakan basis pengetahuan Alibaba Cloud Milvus untuk membuat indeks pengetahuan berlabel dari dokumen layanan pelanggan. Korpus mencakup manual produk, FAQ, serta kebijakan mengenai pengembalian dan penukaran, garansi, dan faktur. Pada akhirnya, Anda akan memiliki halaman Q&A layanan pelanggan yang mengambil jawaban melalui penyaringan berdasarkan tag dan model rerank, menghasilkan respons melalui LLM, serta menampilkan sumber jawaban.
Ikhtisar solusi
Alur kerja keseluruhan adalah: definisikan tag dan buat basis pengetahuan di Konsol → impor dokumen layanan pelanggan secara batch berdasarkan tag → publikasikan versi → lakukan pencarian melalui SDK, dengan opsional menyaring berdasarkan tag → teruskan hasilnya ke LLM untuk menghasilkan jawaban lengkap dengan kutipan sumber → sajikan halaman Q&A menggunakan Flask.
Topik ini berfokus pada tiga aspek spesifik dalam skenario layanan pelanggan: penyaringan berdasarkan tag (metadata), model rerank, dan keterlacakan jawaban. Untuk alur dasar basis pengetahuan (unggah dengan presigned URL, daftarkan data, publikasikan versi) dan implementasi Q&A paling sederhana, lihat Buat aplikasi Q&A basis pengetahuan pribadi.
Sebelum memulai, siapkan 5 hingga 20 dokumen dalam format PDF, DOCX, Markdown, atau TXT. Seluruh proses memerlukan waktu sekitar 20 hingga 30 menit. Waktu parsing bergantung pada jumlah dan ukuran dokumen.
Prasyarat
-
Basis pengetahuan yang dibuat di wilayah yang mendukung fitur basis pengetahuan: Tiongkok (Hangzhou), Tiongkok (Beijing), Tiongkok (Zhangjiakou), atau Tiongkok (Shenzhen). Catat ID basis pengetahuan, yang memiliki format
kd-803ae9b10cc31. -
Pengguna RAM yang dibuat dari Akun Alibaba Cloud Anda, dengan opsi Use Permanent AccessKey dipilih dan kebijakan sistem
AliyunMilvusFullAccessdilampirkan ke pengguna RAM tersebut. -
Titik akhir LLM yang mendukung protokol OpenAI
chat/completions, bersama dengan Kunci API. Sebagai contoh, gunakan titik akhir kompatibel OpenAI dari Alibaba Cloud Model Studio. -
Python 3.8 atau versi lebih baru telah diinstal di mesin lokal Anda.
Untuk panduan keamanan kredensial dan penerapan produksi, lihat Daftar periksa pra-peluncuran.
Langkah 1: Definisikan tag
Tag melampirkan dimensi bisnis (seperti jenis dokumen, lini produk, dan tanggal efektif) ke dokumen. Tag ditulis ke basis pengetahuan saat impor dan digunakan untuk penyaringan saat pencarian. Tag sangat penting untuk membedakan kebijakan, manual, dan FAQ dalam skenario layanan pelanggan.
-
Masuk ke Konsol Alibaba Cloud Milvus dan buka halaman detail basis pengetahuan target.
-
Di bawah Informasi Dasar, temukan Tags dan klik Manage.
-
Pada kotak dialog Tag Management, masukkan nama tag, pilih tipe bidang, lalu klik Add. Topik ini menggunakan tiga tag berikut, semuanya bertipe
string:Nama tag Deskripsi Contoh nilai docType Jenis dokumen policy, manual, faq productLine Lini produk yang berlaku all, phone effectiveDate Tanggal efektif 2026-01-01 -
Setelah menambahkan ketiga tag, klik Done. Di halaman detail, bagian Tags akan menampilkan "3 tags".
Dalam tutorial ini, nilai tag ditulis ke dokumen secara massal saat impor di Langkah 5, sehingga Anda tidak perlu menetapkan nilai di Konsol sekarang. Untuk memberi tag dokumen yang sudah ada di basis pengetahuan, gunakan Set Tags di halaman Data Management, seperti yang dijelaskan di FAQ.
Catatan penggunaan
-
Tipe bidang — Tipe bidang dapat berupa
string,int64,list,float32, ataubool. -
Basis pengetahuan yang sudah ada — Tag dapat ditambahkan ke basis pengetahuan yang sudah ada setelah pembuatan awal, tanpa perlu membangun ulang basis pengetahuan tersebut.
-
Definisi bukan prasyarat untuk menulis nilai — Mendefinisikan tag di Konsol bukan prasyarat untuk menulis nilai tag. Bidang yang belum didefinisikan tetap dapat ditulis bersama dokumen dan digunakan untuk penyaringan, serta menghapus definisi tag tidak menghapus nilai historis yang telah ditulis. Definisi bukan deklarasi bidang indeks independen. Efek praktis dari definisi adalah tampilan di Konsol, manajemen opsi untuk tag bertipe list, serta konversi tipe nilai saat penyaringan menggunakan
string,int64,float32,bool, ataulist. Praktik yang direkomendasikan adalah mendefinisikan tag terlebih dahulu untuk mendapatkan validasi tipe dan kemudahan manajemen, bukan menganggapnya sebagai prasyarat wajib. -
Perubahan definisi tidak membangun ulang data — Kotak dialog memperingatkan bahwa perubahan tag memengaruhi pembuatan indeks, tetapi menambah atau menghapus definisi tag tidak membangun ulang atau memigrasi data yang sudah diimpor, dan nilai tag historis tidak dihapus. Meski demikian, finalisasi definisi tag sebelum impor batch agar tampilan Konsol, manajemen opsi, dan konversi tipe nilai tetap konsisten sejak awal.
Kolom Tag Options menetapkan nilai yang diizinkan untuk suatu tag dan hanya memengaruhi cara nilai dimasukkan di Konsol: jika dibiarkan kosong, penyaringan berdasarkan tag di Konsol memerlukan pengetikan manual nilai tag; setelah Anda memasukkan nilai yang dipisahkan koma (misalnya after-sales,logistics,billing), Konsol beralih ke daftar drop-down dan mencegah kesalahan pengetikan. Fitur ini tidak memvalidasi nilai yang ditulis melalui API — menggunakan AddDocuments untuk mengirimkan nilai di luar opsi tetap berhasil, dan jangkauan nilai harus tetap dijamin oleh manifes impor itu sendiri.
Langkah 2: Siapkan dokumen dan manifes impor
-
Letakkan dokumen layanan pelanggan di direktori lokal
documents/. Contohnya:
kb-demo/
├── documents/
│ ├── shipping-policy.md
│ ├── return-policy.md
│ ├── warranty-policy.md
│ ├── phone-manual.md
│ └── invoice-faq.md
-
Buat file
documents.jsonl. Setiap baris menjelaskan satu dokumen beserta tag-nya. Nama tag harus sesuai dengan yang didefinisikan di Langkah 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"}}
Nama file sebaiknya mencerminkan topik dokumen, dan isinya harus memuat judul lengkap serta konteks yang memadai agar pengambilan informasi dapat menjawab pertanyaan nyata, termasuk contoh pertanyaan yang dikonfigurasi di Langkah 3. Lebih baik mengimpor kebijakan resmi yang berlaku dan hindari menyimpan versi lama yang bertentangan secara bersamaan.
Langkah 3: Konfigurasikan parameter waktu proses
-
Buat direktori proyek dan instal dependensi.
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
Paket-paket di atas secara otomatis menginstal dependensi tambahan, seperti alibabacloud_tea_openapi (diimpor oleh kb_client.py) dan Jinja2 (digunakan oleh Flask untuk rendering templat). Anda tidak perlu menginstalnya secara terpisah.
-
Buat file
config.jsondan isi dengan kredensial serta informasi basis pengetahuan Anda sendiri.
{
"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": "Q&A Basis Pengetahuan Layanan Pelanggan Cerdas",
"system_prompt": "Anda adalah asisten layanan pelanggan perusahaan. Jawab hanya berdasarkan dokumen yang diambil, dengan nada layanan pelanggan yang jelas dan ramah; kutip [Source N] untuk kesimpulan utama. Jika dokumen yang diambil tidak secara eksplisit mencakup pertanyaan pengguna, Anda harus menjawab 'Dokumen yang tersedia tidak secara eksplisit mencakup hal ini.' Jangan menarik kesimpulan dari dokumen yang hanya sebagian relevan, dan jangan menambahkan metode kontak atau saluran layanan di luar dokumen.",
"image_enabled": false,
"sample_questions": [
"Berapa lama setelah pembayaran biasanya produk dikirim?",
"Apakah produk masih bisa diperbaiki setelah masa garansi berakhir?",
"Syarat apa saja yang harus dipenuhi untuk mengajukan pengembalian?"
]
}
}
Pertimbangkan nilai parameter pengambilan dalam skenario ini:
-
min_score=0.35: mengurangi konten dengan relevansi rendah agar tidak masuk ke jawaban. -
semantic_weight=0.7: lebih mengutamakan pencarian semantik untuk mencakup pertanyaan layanan pelanggan yang bersifat percakapan. -
rerank_model_name: menyusun ulang chunk kandidat untuk meningkatkan akurasi hasil teratas. -
tag_filter.conditions: dibiarkan kosong berarti tanpa penyaringan. Untuk penyaringan berdasarkan tag, lihat Langkah 7.
Langkah 4: Buat klien basis pengetahuan
Simpan konten berikut sebagai kb_client.py. File ini mengenkapsulasi unggah dengan tag dan pencarian dengan penyaringan.
"""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:
# Respons sukses tidak mengembalikan field code (nilainya None), sehingga hanya kode eksplisit non-nol yang dianggap gagal.
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:
# URL yang ditandatangani ditandatangani dengan Content-Type kosong; jangan sertakan Content-Type dalam permintaan.
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()
Langkah 5: Buat skrip unggah batch
Simpan konten berikut sebagai upload.py. MetaFields dari AddDocuments berlaku untuk seluruh batch, sehingga skrip terlebih dahulu mengelompokkan dokumen berdasarkan tag lalu mengirimkannya dalam batch berisi maksimal 100 dokumen.
"""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()
Jalankan unggah:
python3 upload.py --manifest documents.jsonl
Output menampilkan batch yang dikelompokkan berdasarkan tag. Misalnya, 5 dokumen dengan 4 kombinasi tag menghasilkan 4 batch:
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'}
Pengembalian sukses dari API unggah hanya berarti parsing asinkron telah diajukan. Buka halaman Data Management di Konsol dan pastikan status dokumen berubah menjadi Processing Complete sebelum melanjutkan ke langkah berikutnya. Kolom tag menampilkan tag yang telah ditulis, seperti productLine=all, docType=faq +1.
Langkah 6: Publikasikan versi
Setelah parsing selesai, dokumen tetap tidak dipublikasikan dan hanya dapat dicari setelah Anda mempublikasikan versi. Operasi ini saat ini hanya didukung di Konsol.
-
Buka halaman detail basis pengetahuan, klik tab Version Management, pastikan ada perubahan tertunda, lalu klik Publish Version.
-
Pada langkah Confirm Changes, tinjau log perubahan (data yang baru ditambahkan tercantum satu per satu) lalu klik Next.
-
Pada langkah Enter Description, masukkan catatan rilis (maksimal 200 karakter) lalu klik Publish.
-
Di Version History, pastikan status versi baru adalah Published dan catat nomor versinya. Publikasi pertama adalah
Saatv1, diikuti olehv2danv3.knowledge_base_versiondiconfig.jsonmenggunakanLATEST_PUBLISHED, versi terbaru yang dipublikasikan akan dicari secara otomatis. Anda juga dapat menentukan nomor versi eksplisit. Jangan gunakanDRAFTuntuk menyediakan layanan Q&A yang stabil.
Kuota versi dan penghapusan
-
Kuota default — Secara default, maksimal 3 versi yang dipublikasikan dapat ada secara bersamaan.
-
Lingkup kuota — Batasan ini dihitung per penyewa dan tidak meningkat seiring spesifikasi CU instans.
-
Penambahan kuota — Untuk menaikkan batas, ajukan tiket untuk evaluasi. Saat ini tidak tersedia entri permohonan kuota mandiri untuk pengguna.
-
Perilaku saat mencapai batas — Ketika batas tercapai, tombol Publish Version menjadi abu-abu, meskipun halaman tetap menampilkan "Ada N perubahan tertunda untuk dipublikasikan". Hapus versi lama yang tidak lagi diperlukan di Version History sebelum mempublikasikan kembali.
-
Pembaruan sering — Jika dokumen sering diperbarui, simpan hanya versi yang sedang berlaku ditambah satu versi historis terbaru.
Penghapusan versi bersifat ireversibel. Setelah dihapus, versi tersebut langsung tidak dapat dicari (Backend membersihkan data secara asinkron). Sebelum menghapus, pastikan tidak ada aplikasi yang terikat pada nomor versi tersebut, dan alihkan aplikasi yang masih menggunakannya ke versi baru.
Langkah 7: Pencarian dengan penyaringan tag
Konfigurasikan kondisi filter di retrieval.tag_filter.conditions pada config.json untuk membatasi cakupan pencarian ke tag tertentu. Misalnya, untuk mencari hanya dokumen kebijakan:
{
"tag_filter": {
"relation": "and",
"conditions": [
{"field": "docType", "op": "=", "value": "policy"}
]
}
}
relation mendukung and (semua kondisi terpenuhi) dan or (salah satu kondisi terpenuhi). op mendukung operator berikut:
| Operator | Perilaku yang diamati |
= |
Berlaku. Pencocokan eksak (untuk tag int64, angka atau string sama-sama berfungsi) |
in, not in |
Berlaku. Apakah nilai termasuk atau tidak termasuk dalam himpunan yang diberikan |
≠, >, <, ≥, ≤, empty, not empty, start with, end with |
Tidak berlaku. Kondisi diabaikan, semua data dikembalikan, dan tidak ada error yang dilaporkan |
contains, not contains |
Tidak disarankan. Alias untuk in/not in, bukan pencocokan substring |
Satu-satunya operator yang benar-benar berfungsi adalah =, in, dan not in. Operator lain muncul dalam daftar "Supported operators" pada error 400 Unsupported tag filter operator, tetapi pengujian menunjukkan bahwa ketika operator tersebut digunakan, kondisi diabaikan diam-diam dan semua data tanpa filter dikembalikan. Selain itu, contains dan not contains hanyalah alias untuk in/not in, bukan pencocokan substring. Mengirimkan fragmen string menghasilkan 0 hasil.
Untuk mengatasi keterbatasan ini:
-
Untuk menyaring rentang numerik atau tanggal, gunakan
indengan nilai-nilai yang dienumerasi. -
Untuk memeriksa tag kosong, gunakan
= ""sebagai gantinya. -
Setelah mengonfigurasi kondisi filter, selalu bandingkan jumlah total hasil dengan hasil tanpa filter untuk memastikan filter benar-benar berlaku.
Selain itu, jikafielddiatur ke nama tag yang tidak didefinisikan, API juga tidak melaporkan error dan hanya mengembalikan 0 hasil. Menetapkanopke alias sepertieq,==,equal, ataulikemenghasilkan error400 Unsupported tag filter operator.
Menggunakan korpus dalam topik ini sebagai contoh, cakupan pencarian di bawah kondisi filter berbeda:
| Kondisi filter | Dokumen yang cocok |
| Tidak ada kondisi yang ditetapkan | Semua dokumen |
docType = policy |
Kebijakan pengiriman, pengembalian dan penukaran, serta garansi |
docType = faq |
FAQ faktur |
docType = policy dan productLine = phone |
Kebijakan garansi |
docType = faq atau docType = manual (relation adalah or) |
FAQ faktur, manual ponsel |
effectiveDate = 2026-01-01 |
Dua kebijakan yang berlaku pada tanggal tersebut |
Langkah 8: Buat layanan Q&A
Simpan konten berikut sebagai 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 "LLM dinonaktifkan. Lihat hasil pengambilan di bawah."
if not results:
return "Dokumen yang tersedia tidak secara eksplisit mencakup hal ini."
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": "Pertanyaan tidak boleh kosong"}), 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)
Simpan konten berikut sebagai 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">Jawaban dihasilkan dari konten basis pengetahuan yang dipublikasikan, dengan sumber pengambilan ditampilkan.</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="Masukkan pertanyaan Anda"></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>
Atribut onclick pada tombol pertanyaan contoh harus dibungkus dengan tanda kutip tunggal (onclick='setQ({{ q|tojson }})'). Jika Anda menggunakan tanda kutip ganda, tanda kutip ganda JSON yang dihasilkan oleh tojson akan menutup atribut HTML lebih awal, sehingga tombol tidak merespons klik.
Langkah 9: Jalankan dan verifikasi
-
Jalankan layanan.
python3 app.py
-
Buka
http://127.0.0.1:7860di browser, klik pertanyaan contoh atau ketik secara manual, lalu klik Send. -
Anda juga dapat memanggil API secara langsung untuk verifikasi.
curl -sS http://127.0.0.1:7860/api/ask \
-H 'Content-Type: application/json' \
-d '{"question":"Berapa lama setelah pembayaran biasanya produk dikirim?"}'
Verifikasi empat kriteria penerimaan berikut:
-
Halaman terbuka normal, dan mengirimkan pertanyaan mengembalikan jawaban beserta sumber pengambilan.
-
Setiap
[Source N]dalam jawaban memiliki potongan dokumen yang sesuai di area sumber di bawahnya. -
Ketika pertanyaan mencakup konten yang tidak ada di dokumen, jawabannya adalah "Dokumen yang tersedia tidak secara eksplisit mencakup hal ini", bukan fakta yang dibuat-buat.
-
Setelah menambahkan dokumen dan mempublikasikan versi kembali di Konsol, halaman dapat mencari konten baru tersebut.
Rekomendasi penyetelan
-
Sesuaikan panjang chunk berdasarkan bentuk dokumen: dokumen layanan pelanggan umumnya berupa entri FAQ pendek dan klausa kebijakan. Di halaman detail basis pengetahuan, pada bagian Processing Policies, klik Create Policy, lalu pilih Smart Splitting atau Split by Length sebagai metode pemotongan. Perhatikan bahwa panjang segmen maksimum diukur dalam karakter (default 512 karakter). Untuk skenario entri pendek, atur menjadi 380 hingga 580 karakter (sekitar 256 hingga 384 token). Setelah kebijakan dibuat, tentukan kebijakan tersebut saat impor melalui
AddDocumentsRequest.strategy_id. Skripupload.pydalam tutorial ini tidak mengatur field ini; untuk menggunakan kebijakan pemrosesan khusus, tambahkan field tersebut ke permintaanAddDocuments. -
Setel reranking dan
min_scoresecara bersamaan: setelah Anda mengaktifkanrerank_model_name, skor rerank dan skor kemiripan vektor tidak berada pada skala yang sama. Mempertahankanmin_scoreasli mungkin terlalu sedikit memangkas hasil (dalam pengujian, tiga pertanyaan layanan pelanggan masing-masing hanya menyisakan satu hasil setelah reranking diaktifkan). Jika pertanyaan memerlukan jawaban gabungan dari beberapa dokumen, turunkanmin_scoresecara tepat saat mengaktifkan reranking. -
Waspada terhadap kesimpulan salah akibat dokumen yang hanya sebagian relevan: ketika pertanyaan secara harfiah terkait dengan dokumen tetapi tidak tercakup secara semantik (misalnya, dokumen hanya menjelaskan waktu pengiriman sementara pengguna menanyakan apakah tersedia pembayaran di tempat), LLM mungkin menarik kesimpulan salah darinya. Dalam
system_prompt, secara eksplisit minta model untuk menjawab bahwa ia tidak tahu jika dokumen tidak secara langsung mencakup pertanyaan, dan larang inferensi dari dokumen yang hanya sebagian relevan. Lakukan spot-check dengan pertanyaan pelanggan nyata sebelum peluncuran. -
Uji dengan pertanyaan pelanggan nyata, bukan hanya judul manual. Nilai
semantic_weightyang lebih tinggi (misalnya 0,7) lebih baik dalam mencakup ekspresi percakapan. -
Lebih baik mengimpor kebijakan resmi yang berlaku dan hindari menyimpan versi lama yang bertentangan secara bersamaan. Setelah pembaruan kebijakan, publikasikan kembali versi dan bedakan versi menggunakan tag
effectiveDate.
Pahami skor pencarian
Setiap hasil pencarian juga mengembalikan scoreDetails, misalnya {"keywordScore": 0.368, "semanticScore": 0.819}, yang membantu Anda menilai apakah hasil tersebut berasal terutama dari pencocokan kata kunci atau semantik.
Ketiga skor tersebut berarti:
-
keywordScoreadalah kemiripan kata kunci. -
semanticScoreadalah kemiripan vektor saat reranking dinonaktifkan, dan skor model rerank saat reranking diaktifkan. -
Skala skor berubah tergantung model, dengan atau tanpa reranking, serta bobot yang berbeda. Skor tidak dapat dibandingkan lintas konfigurasi, dan tidak ada ambang batas yang direkomendasikan secara universal: aturscoreadalah skor peringkat dan penyaringan akhir yang diperoleh dengan memberi bobot kedua skor tersebut, dihitung sebagaiscore ≈ (1-semantic_weight) × keywordScore + semantic_weight × semanticScore(fitur peringkat tambahan mungkin juga ditambahkan).min_scoremelakukan penyaringan berdasarkan skor akhir ini.min_scoreke 0 terlebih dahulu, ambil sejumlah hasil lalu tandai relevansinya secara manual, kemudian pilih ambang batas berdasarkan distribusi recall dan false recall. Kalibrasi ulang setiap kali Anda menyesuaikansemantic_weightatau mengaktifkan/menonaktifkan reranking.
FAQ
| Gejala | Penyebab dan solusi |
Pencarian mengembalikan 400 Unsupported tag filter operator |
op menggunakan alias seperti eq, ==, atau like. Gunakan =, in, atau not in sebagai gantinya. |
| Penyaringan tag ditambahkan tetapi jumlah hasil persis sama dengan tanpa penyaringan | Operator yang tidak berlaku digunakan (seperti >, ≥, ≠, atau empty). Hanya =, in, dan not in yang berlaku. |
| Penyaringan tag selalu mengembalikan 0 hasil | field diatur ke nama tag yang tidak didefinisikan (API tidak melaporkan error). Verifikasi ejaan nama tag di halaman detail basis pengetahuan → Tags → Manage; juga pastikan tag benar-benar ditulis untuk dokumen tersebut saat impor. Mendefinisikan tag di Konsol bukan prasyarat untuk menulis nilai tag. Untuk detailnya, lihat Catatan penggunaan di Langkah 1. |
| Tombol Publish Version berwarna abu-abu, tetapi halaman menunjukkan ada perubahan tertunda | Batas 3 versi yang dipublikasikan telah tercapai (arahkan kursor ke tombol untuk melihat petunjuk). Hapus versi lama di Version History, lalu publikasikan. |
Pencarian mengembalikan 404 Knowledge base version ... does not exist |
Belum ada versi yang dipublikasikan, atau knowledge_base_version tidak sesuai dengan nomor versi aktual. Publikasikan versi terlebih dahulu di Konsol. |
| Unggah berhasil tetapi tidak ada yang dapat dicari | Unggah dan parsing bersifat asinkron. Tunggu hingga halaman Data Management di Konsol menampilkan Processing Complete, lalu publikasikan versi kembali. |
| Panggilan mengembalikan 401 atau 403 | AccessKey tidak valid, atau pengguna RAM tidak memiliki AliyunMilvusFullAccess. |
| Kegagalan koneksi sesekali saat PUT ke OSS selama unggah | Jitter jaringan. Cukup coba ulang file tersebut. |
| Dokumen historis tidak memiliki tag dan tidak dapat ditemukan melalui penyaringan tag | Tag ditulis saat impor; dokumen yang diimpor sebelum definisi tag tidak cocok dengan penyaringan tag. Anda dapat Set Tags untuk dokumen individual di halaman Data Management, atau impor ulang. |
Daftar periksa pra-peluncuran
-
Penyimpanan kredensial — Simpan Pasangan Kunci Akses dan Kunci API LLM hanya di file lokal
config.json. Jangan commit ke repositori kode atau bagikan di grup chat. Setelah demo, segera putar atau hapus kredensial sementara tersebut. -
Hak istimewa minimal — Gunakan pengguna RAM khusus dengan hak istimewa minimal yang dapat diputar. Jangan gunakan AccessKey Akun Alibaba Cloud dalam jangka panjang.
-
Konten yang sah — Impor hanya dokumen yang Anda berwenang tangani. Setiap jawaban harus dapat dilacak ke potongan sumbernya.
-
Penerapan produksi — Untuk penerapan eksternal, jangan terus menggunakan server pengembangan Flask. Gunakan server WSGI produksi dan tambahkan autentikasi, HTTPS, penyembunyian log akses, pembatasan laju, serta audit.
-
Cadangan manusia — Untuk Q&A kebijakan kritis, sediakan opsi cadangan manusia.