본문으로 건너뛰기
Sursa Developers

Q&A 봇 만들기

이 가이드에서는 지식 베이스를 바탕으로 질문에 답하는 봇의 핵심 부분을 만들어요. 채팅 도구, 헬프 센터, 제품 안 어디에서든 쓸 수 있어요. 어시스턴트가 관련 지식을 찾아 답변하고 사용한 카드를 인용해요. 코드는 질문을 보내고 결과를 보여 주기만 하면 돼요.

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] 같은 참조는 sources 항목의 ref와 대응해요. 독자가 답변을 확인할 수 있도록 각 참조를 카드(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"는 대부분의 질문에 적합해요.

답변 스트리밍하기

채팅 화면에서는 답변이 작성되는 동안 보여 주세요. "stream": true와 함께 보낸 POST /v1/chat 요청은 text/event-stream으로 응답해요. 메시지마다 이벤트가 하나씩 오며, 각 이벤트에는 id: 줄(스트림에서의 번호, 1부터)과 JSON을 담은 data: 줄이 있어요. JSON의 type 필드가 이벤트의 종류를 알려 줘요.

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 없이 끝나요. 아래를 참고하세요.
followupsdone 뒤에 와요. 버튼으로 제시할 수 있는 다음 질문 제안이에요.
그 밖의 유형건너뛰세요. 새 이벤트 유형은 언제든 추가될 수 있어요.

:로 시작하는 줄(답변을 준비하는 동안 15초마다 보내는 : ping 등)은 주석이에요. 건너뛰세요.

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은 /v1/ask처럼 완전한 답변을 JSON으로 반환해요.

끊긴 스트림 재개하기

연결은 끊어지기 마련이에요. 모바일 네트워크가 바뀌거나, 프록시가 타임아웃되거나, 서버가 재시작돼요. 질문마다 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, 그리고 조직이 질문 한도를 다 썼거나 동시에 진행 중인 답변이 너무 많을 때 Retry-After와 함께 오는 429가 있어요.

스트림이 시작된 뒤에는 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를 지정하고 위의 재개 규칙을 적용하기.
  • : ping 주석으로 연결이 유지되도록 15초보다 긴 스트림 읽기 타임아웃.
  • Markdown으로 렌더링한 답변과 출처로 연결된 참조.
  • empty가 설정되었을 때의 명확한 메시지와 사람에게 문의할 수 있는 방법.
  • Retry-After만큼 기다리는 429 처리.
  • 지식 베이스가 점점 나아지도록 사용자 평가 보내기.