ข้ามไปยังเนื้อหา
Sursa Developers

สร้างบอตถาม-ตอบ

คู่มือนี้สร้างแกนหลักของบอตที่ตอบคำถามจากฐานความรู้ของคุณ ไม่ว่าจะในเครื่องมือแชต ในศูนย์ช่วยเหลือ หรือภายในผลิตภัณฑ์ของคุณ ผู้ช่วยค้นหาความรู้ที่เกี่ยวข้องและตอบคำถาม พร้อมอ้างอิงการ์ดที่ใช้ ส่วนโค้ดของคุณส่งคำถามและแสดงผลลัพธ์

คุณต้องมีคีย์ที่มี ขอบเขตสิทธิ์ search และ ask คำตอบใช้เฉพาะการ์ดที่คีย์มีสิทธิ์อ่าน บทบาท โปรไฟล์ข้อมูล และคอลเลกชันของคีย์จึงกำหนดว่าบอตพูดถึงเรื่องใดได้บ้าง บอตสำหรับลูกค้าควรใช้คีย์ที่มองเห็นเฉพาะคอลเลกชันสาธารณะ

สามวิธีในการรับคำตอบ

เอนด์พอยต์สิ่งที่ส่งคืนใช้สำหรับ
POST /v1/searchข้อความที่ตัดมาซึ่งตรงกับคำค้น เรียงจากที่ตรงที่สุด ไม่มีคำตอบที่เรียบเรียงเป็นข้อความช่องค้นหา “บทความที่เกี่ยวข้อง” หรือการจัดลำดับผลลัพธ์ของคุณเอง
POST /v1/askคำตอบที่สมบูรณ์พร้อมแหล่งที่มาในการตอบกลับเดียวบอตที่โพสต์ข้อความเดียวต่อหนึ่งคำตอบ และงานเบื้องหลังฝั่งเซิร์ฟเวอร์
POST /v1/chatคำตอบเป็นสตรีมของอีเวนต์ขณะที่กำลังเขียนหน้าจอแชตที่ผู้ใช้ควรเห็นคำตอบค่อยปรากฏขึ้น
curl -X POST https://api.sursa.com/v1/search \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "how do I return a damaged item", "collection": "support"}'

ผลที่พบแต่ละรายการคือข้อความที่ตัดมาจากการ์ด ประกอบด้วย doc_id และ doc_name ของการ์ด heading ของส่วนนั้น text ของข้อความที่ตัดมา หน้าหรือแถวของตารางที่พบ รวมถึงความน่าเชื่อถือและการตรวจสอบของการ์ด

collection เลือกคอลเลกชันที่จะค้นหา หากไม่ระบุ ระบบจะใช้คอลเลกชันที่ชื่อ default ส่วน tags และ card_ids จำกัดการค้นหาให้เหลือเฉพาะการ์ดที่มีแท็กเหล่านั้นหรือการ์ดที่ระบุ

ถามและรับคำตอบที่สมบูรณ์

curl -X POST https://api.sursa.com/v1/ask \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "How do I return a damaged item?", "collection": "support"}'

ข้อความของคำตอบใน answer เป็น Markdown การอ้างอิงในข้อความ เช่น [1] หรือ [1, 3] ตรงกับ ref ของรายการใน sources ให้ลิงก์การอ้างอิงแต่ละรายการไปยังการ์ด (doc_id) เพื่อให้ผู้อ่านตรวจสอบคำตอบได้

ตัวเลขในข้อความที่ยืนยันกับเอกสารไม่ได้อาจปรากฏเป็นเครื่องหมาย เช่น ⟦n:a1⟧ โดย verification.items อธิบายเครื่องหมายแต่ละรายการ ได้แก่ marker reason และข้อความ original หากโค้ดของคุณไม่จัดการเครื่องหมาย ให้แทนที่แต่ละเครื่องหมายด้วยข้อความ original ของเครื่องหมายนั้น

เมื่อไม่มีคำตอบ

การตอบกลับเป็น 200 เสมอ เมื่อผู้ช่วยตอบไม่ได้ empty จะบอกสาเหตุ และ answer จะมีข้อความสั้นที่คุณแสดงให้ผู้ใช้เห็นได้:

emptyความหมาย
no_hitsไม่มีสิ่งใดในฐานความรู้ที่ตรงกับคำถาม
restrictedหัวข้อนี้อยู่ในการ์ดที่คีย์ไม่มีสิทธิ์อ่าน
guardrailคำถามอยู่นอกเหนือสิ่งที่องค์กรอนุญาตให้ผู้ช่วยตอบ
no_data_for_periodคำถามถามถึงช่วงเวลาที่ไม่มีเอกสาร

no_hits เป็นสัญญาณที่มีประโยชน์ เพราะแสดงให้เห็นว่าผู้ใช้ถามเรื่องใดที่ฐานความรู้ยังไม่ครอบคลุม

Fast และ Precise

ส่ง "quality": "precise" เพื่อให้ได้คำตอบที่ละเอียดถี่ถ้วนขึ้นสำหรับคำถามที่ยาก แลกกับเวลาที่นานขึ้น ค่าเริ่มต้น "fast" เหมาะกับคำถามส่วนใหญ่

สตรีมคำตอบ

ในหน้าจอแชต ให้แสดงคำตอบขณะที่กำลังเขียน POST /v1/chat ที่มี "stream": true จะตอบกลับด้วย text/event-stream หนึ่งอีเวนต์ต่อหนึ่งข้อความ แต่ละอีเวนต์มีบรรทัด id: (หมายเลขในสตรีม เริ่มจาก 1) และบรรทัด data: ที่มี JSON ฟิลด์ type ของ JSON บอกว่าเป็นอีเวนต์ประเภทใด

id: 1
data: {"type":"meta","sources":3,"timings":{…},"answer_id":"…"}

id: 2
data: {"type":"sources","sources":[{"ref":1,"doc_id":"8c1d…","doc_name":"Returns","text":"…"}]}

id: 3
data: {"type":"token","text":"Send a photo of the item "}

id: 4
data: {"type":"token","text":"within 7 days [1]."}

id: 5
data: {"type":"done","answer":"Send a photo of the item within 7 days [1].","question_id":9812,"sources":[…]}
typeสิ่งที่ต้องทำ
metaคำตอบเริ่มแล้ว เก็บ answer_id ไว้หากคุณบันทึกคำตอบ
sourcesการ์ดที่คำตอบอ้างอิง อาจมามากกว่าหนึ่งครั้ง ให้ใช้รายการล่าสุด
tokenส่วนของข้อความของคำตอบ ให้ต่อท้ายสิ่งที่แสดงอยู่
doneคำตอบเสร็จสมบูรณ์ มีฟิลด์เหมือนการตอบกลับของ /v1/ask
errorคำตอบหยุดลง สตรีมจบโดยไม่มี done ดูด้านล่าง
followupsหลัง done คือคำถามต่อเนื่องที่แนะนำ ซึ่งคุณนำเสนอเป็นปุ่มได้
อย่างอื่นทั้งหมดข้ามไป เพราะอาจมีอีเวนต์ประเภทใหม่ได้ทุกเมื่อ

บรรทัดที่ขึ้นต้นด้วย : (เช่น : ping ที่ส่งทุก 15 วินาทีระหว่างที่กำลังเตรียมคำตอบ) เป็นคอมเมนต์ ให้ข้ามไป

messages เก็บบทสนทนาจนถึงตอนนี้ เรียงจากเก่าไปใหม่ และจบด้วยคำถามของผู้ใช้ ให้ส่งรอบการสนทนาล่าสุดสองสามรอบ เพื่อให้เข้าใจคำถามต่อเนื่อง เช่น “แล้วคำสั่งซื้อจากต่างประเทศล่ะ?”

// Streams an answer and calls onText with each piece of text. Node.js 20+ or a browser.
export async function streamAnswer({ messages, collection, requestId, lastEventId, onText }) {
  const response = await fetch("https://api.sursa.com/v1/chat", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SURSA_API_KEY}`,
      "Content-Type": "application/json",
      Accept: "text/event-stream",
      ...(lastEventId ? { "Last-Event-ID": String(lastEventId) } : {}),
    },
    body: JSON.stringify({ messages, collection, stream: true, request_id: requestId }),
  });
  if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);

  const decoder = new TextDecoder();
  let buffer = "";
  let event = { id: 0, data: "" };
  let done = null;
  for await (const piece of response.body) {
    buffer += decoder.decode(piece, { stream: true });
    let newline;
    while ((newline = buffer.indexOf("\n")) >= 0) {
      const line = buffer.slice(0, newline).replace(/\r$/, "");
      buffer = buffer.slice(newline + 1);
      if (line.startsWith("id:")) event.id = Number(line.slice(3).trim());
      else if (line.startsWith("data:")) event.data += line.slice(5).trim();
      else if (line === "" && event.data) {
        const message = JSON.parse(event.data);
        if (message.type === "token") onText(message.text, event.id);
        else if (message.type === "done") done = message;
        else if (message.type === "error") throw Object.assign(new Error(message.message), { event: message, id: event.id });
        event = { id: 0, data: "" };
      }
    }
  }
  if (!done) throw new Error("the stream ended before the answer was complete"); // resume it
  return done;
}

หากไม่มี "stream": true คำขอ POST /v1/chat จะส่งคืนคำตอบที่สมบูรณ์เป็น JSON เหมือน /v1/ask

ทำต่อจากสตรีมที่ขาด

การเชื่อมต่อขาดได้เสมอ เช่น เครือข่ายมือถือเปลี่ยน พร็อกซีหมดเวลา หรือเซิร์ฟเวอร์ของคุณรีสตาร์ต ให้กำหนด request_id ให้ทุกคำถาม แล้วคำตอบจะอยู่รอดผ่านการขาดการเชื่อมต่อ:

  • เมื่อมี request_id คำตอบจะถูกเตรียมโดยไม่ขึ้นกับการเชื่อมต่อ การตัดการเชื่อมต่อไม่ได้หยุดคำตอบ คำตอบจะเสร็จสมบูรณ์และถูกบันทึกไว้
  • หากต้องการทำต่อ ให้ส่ง คำขอเดิม อีกครั้งด้วย request_id เดิม พร้อมหมายเลขของอีเวนต์สุดท้ายที่ได้รับในเฮดเดอร์ Last-Event-ID
  • ใช้ request_id หนึ่งค่า (เช่น UUID) ต่อหนึ่งคำถาม และใช้ค่าเดิมในทุกการลองใหม่ของคำถามนั้น คำถามใหม่ได้ request_id ใหม่

สิ่งที่คำขอซ้ำได้รับขึ้นอยู่กับสถานะของคำตอบ:

สถานการณ์คำขอซ้ำได้รับ
คำตอบยังเขียนอยู่ หรือเพิ่งเสร็จอีเวนต์หลัง Last-Event-ID ด้วยหมายเลขเดิม สตรีมเพียงดำเนินต่อไป
คำตอบเสร็จไปแล้วหลายนาทีคำตอบที่บันทึกไว้ในรูป token ที่มีข้อความทั้งหมดและ done โดยนับหมายเลขใหม่จาก 1 ระบบไม่ถามคำถามนั้นซ้ำ
สตรีมไล่ต่อจากหมายเลขนั้นไม่ได้คำตอบตั้งแต่ต้น โดยนับหมายเลขจาก 1

โค้ดของคุณจัดการทั้งสามกรณีได้ด้วยกฎข้อเดียว: หากอีเวนต์แรกที่ได้รับมีหมายเลขไม่มากกว่า Last-Event-ID แสดงว่าคำตอบเริ่มใหม่ ให้ล้างสิ่งที่แสดงไว้แล้วสร้างใหม่ มิฉะนั้นให้ต่อท้าย

import { randomUUID } from "node:crypto";

async function answerWithResume(messages, collection, show) {
  const requestId = randomUUID();
  let text = "";
  let lastId = 0;
  for (let attempt = 1; ; attempt++) {
    let first = true;
    try {
      return await streamAnswer({
        messages, collection, requestId, lastEventId: lastId,
        onText(piece, id) {
          if (first && lastId && id <= lastId) text = ""; // the answer starts over
          first = false;
          text += piece;
          lastId = id;
          show(text);
        },
      });
    } catch (error) {
      const retry = error.event ? error.event.retry === true : true; // a network error, or a server restart
      if (!retry || attempt === 5) throw error;
      await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
    }
  }
}

Sursa API อาจทำงานบนเซิร์ฟเวอร์หลายเครื่อง สตรีมจะไล่ต่อจาก Last-Event-ID ได้เฉพาะบนเซิร์ฟเวอร์ที่กำลังเขียนคำตอบนั้น หากคำขอซ้ำไปถึงเซิร์ฟเวอร์อื่น คำขอนั้นจะได้รับคำตอบที่บันทึกไว้เมื่อคำตอบเสร็จแล้ว หรือคำตอบจะถูกเขียนใหม่ตั้งแต่ต้น กฎข้างต้นครอบคลุมทั้งสองกรณี และคำถามจะไม่ถูกนับซ้ำสองครั้ง

หากไม่มี request_id การปิดการเชื่อมต่อจะยกเลิกคำตอบ

ข้อผิดพลาดในสตรีม

ข้อผิดพลาดก่อนสตรีมเริ่มเป็นข้อผิดพลาด HTTP ตามปกติ ได้แก่ 401 403 422 หรือ 429 พร้อม Retry-After เมื่อองค์กรใช้ ขีดจำกัดจำนวนคำถาม หมดแล้ว หรือมี คำตอบที่กำลังสร้าง พร้อมกันมากเกินไป

เมื่อสตรีมเริ่มแล้ว สถานะ HTTP จะเป็น 200 เสมอ และปัญหาจะมาในรูปอีเวนต์ error:

{"type": "error", "code": "server_restart", "retry": true, "message": "…", "answer_id": "…"}
  • retry: true หมายความว่าคำตอบหยุดลงเพราะเซิร์ฟเวอร์ เช่น ระหว่างการอัปเดต และคุณส่งคำขอเดิมซ้ำได้โดยไม่ต้องแก้ไข
  • รหัสอื่น เช่น timeout ระบุไว้ใน เอกสารอ้างอิง API ให้จัดการรหัสที่ไม่รู้จักเหมือนข้อผิดพลาดของสตรีมอื่น คือแจ้งผู้ใช้และให้ผู้ใช้ถามอีกครั้ง

ให้ผู้ใช้ให้คะแนนคำตอบ

ทุกคำตอบมี question_id ส่งคะแนนของผู้ใช้ไปยัง POST /v1/questions/{question_id}/feedback โดยใช้ 1 สำหรับมีประโยชน์ และ -1 สำหรับไม่มีประโยชน์ พร้อม reason และ note ซึ่งไม่บังคับ คะแนนช่วยให้องค์กรเห็นว่าการ์ดใดต้องปรับปรุง

curl -X POST https://api.sursa.com/v1/questions/9812/feedback \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value": -1, "reason": "outdated", "note": "The return window is now 14 days."}'

เช็กลิสต์สำหรับบอตที่ใช้งานจริง

  • คีย์ที่มีเพียง search และ ask และจำกัดไว้เฉพาะคอลเลกชันที่กลุ่มผู้ใช้ของบอตมีสิทธิ์เห็น
  • request_id หนึ่งค่าต่อหนึ่งคำถาม และกฎการทำต่อข้างต้น
  • ค่าหมดเวลาในการอ่านสตรีมที่นานกว่า 15 วินาที เพื่อให้คอมเมนต์ : ping รักษาการเชื่อมต่อไว้
  • แสดงคำตอบเป็น Markdown โดยลิงก์การอ้างอิงไปยังแหล่งที่มา
  • ข้อความที่ชัดเจนเมื่อมีการตั้งค่า empty และช่องทางติดต่อเจ้าหน้าที่
  • จัดการ 429 ด้วยการรอตาม Retry-After
  • ส่งคะแนนกลับมา เพื่อให้ฐานความรู้ดีขึ้นเรื่อยไปตามเวลา