Build a RAG Chatbot from Scratch — Part 3: Streaming Responses with SSE

Users expect ChatGPT-style streaming — tokens appearing as they’re generated, not all at once after a 5-second wait.

Step 1: Streaming LLM Response

# app/generation_streaming.py
from openai import OpenAI
from app.config import settings
from app.retrieval import retrieve
from app.prompts import build_rag_prompt

client = OpenAI(api_key=settings.openai_api_key)

def answer_question_stream(query: str):
    """RAG pipeline with streaming response — yields tokens as they arrive."""
    chunks = retrieve(query)

    if not chunks:
        yield "No relevant documents found."
        return

    context, system_prompt = build_rag_prompt(query, chunks)

    stream = client.chat.completions.create(
        model=settings.model,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": f"Context:\n{context}\n\nQuestion: {query}"}
        ],
        temperature=0.3,
        max_tokens=1000,
        stream=True
    )

    for chunk in stream:
        content = chunk.choices[0].delta.content
        if content:
            yield content

Step 2: SSE Endpoint

# app/api/chat_streaming.py
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.generation_streaming import answer_question_stream
from app.retrieval import retrieve
import json

router = APIRouter(prefix="/chat", tags=["chat"])

class Question(BaseModel):
    query: str

@router.post("/stream")
async def chat_stream(question: Question):
    async def event_stream():
        # First, send the sources
        chunks = retrieve(question.query)
        sources = [{"source": c["metadata"]["source"], "similarity": c["similarity"]} for c in chunks]
        yield f"event: sources\ndata: {json.dumps(sources)}\n\n"

        # Then stream the answer tokens
        for token in answer_question_stream(question.query):
            yield f"data: {json.dumps({'token': token})}\n\n"

        # Signal completion
        yield "data: [DONE]\n\n"

    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no"  # Disable nginx buffering
        }
    )

Step 3: Client-Side SSE Consumer

// chat-client.js
async function streamChat(query) {
  const response = await fetch('/chat/stream', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query })
  });

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let answerEl = document.getElementById('answer');

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    const text = decoder.decode(value);
    const lines = text.split('\n');

    for (const line of lines) {
      if (line.startsWith('data: ') && !line.includes('[DONE]')) {
        const data = JSON.parse(line.slice(6));
        if (data.token) {
          answerEl.textContent += data.token;
        }
      } else if (line.startsWith('event: sources')) {
        // Handle sources event
      }
    }
  }
}

Step 4: Streaming with Source Citations

Enhance the prompt to include citations that appear inline:

SYSTEM_PROMPT_STREAMING = """Answer the question based ONLY on the provided context.
Cite sources inline using the format [1].

Example: "The vacation policy allows 20 days off [1]. Sick leave is 10 days [2]."

If the answer isn't in the context, say exactly: "I don't have enough information to answer this."
"""

Step 5: Token Usage Tracking

from app.core.database import SessionLocal
from app.models import QueryLog
from datetime import datetime, timezone

def log_query(query: str, tokens_used: int, sources_count: int):
    db = SessionLocal()
    log = QueryLog(
        query=query,
        tokens_used=tokens_used,
        sources_count=sources_count,
        created_at=datetime.now(timezone.utc)
    )
    db.add(log)
    db.commit()
    db.close()

Verification

# Stream a response (curl can show SSE)
curl -N -X POST http://localhost:8000/chat/stream \
  -H "Content-Type: application/json" \
  -d '{"query": "What is RAG?"}'

# Output (tokens arrive progressively):
# event: sources
# data: [{"source":"rag_paper.pdf","similarity":0.95}]
#
# data: {"token":"RAG"}
# data: {"token":" stands"}
# data: {"token":" for"}
# data: {"token":" Retrieval-Augmented"}
# ...
# data: [DONE]

Summary

  • SSE (Server-Sent Events) streams tokens as the LLM generates them
  • Two events: sources (metadata) and data (tokens)
  • Client-side uses ReadableStream to consume incremental data
  • Citations embedded in the prompt format: [1], [2]
  • Usage tracking logged to the database for cost monitoring

← Part 2 | Part 4 →


Advertisement