实战客服 RAG 03:实现检索、转人工和问答 API

先理解:API 返回的不应该只有一句答案

客服 RAG 的输出至少包含三类信息:回答内容、引用来源、是否转人工。只返回一句自然语言,看起来像聊天机器人,但不适合生产排查。用户投诉时,你需要知道它引用了哪条知识、为什么没有转人工、当时检索分数是多少。

本篇先实现一个可调试的问答 API。它会从向量库取回候选 Chunk,再用阈值和风险规则决定回答还是转人工。暂时不用复杂的 LLM Prompt,是为了先把检索链路和业务控制跑通。

为什么要有转人工逻辑

客服系统不是考试系统,不知道答案时不能编。遇到低置信度、高风险承诺、知识缺失时,转人工是正确结果,不是失败结果。

你可以把 handoff=true 理解成系统的安全阀。早期宁可多转一点人工,也不要让机器人用不确定的信息回答用户。

调试顺序

如果回答不对,先看检索结果,不要马上改 Prompt。确认 top3 里有没有正确知识、来源是否正确、分数阈值是否合理。检索没拿到正确材料时,生成模型越强,越可能把错误包装得很自然。

本篇实现 /ask 接口。为了让教程可直接跑通,回答先用模板生成;接入 LLM 时只需替换 compose_answer

检索器

src/retriever.py

python
from chromadb import PersistentClient
from sentence_transformers import SentenceTransformer
from src.config import INDEX_DIR, COLLECTION_NAME, EMBEDDING_MODEL

model = SentenceTransformer(EMBEDDING_MODEL)
client = PersistentClient(path=str(INDEX_DIR))
collection = client.get_or_create_collection(
    COLLECTION_NAME,
    metadata={"hnsw:space": "cosine"},
)

def retrieve(query: str, product: str = "all", top_k: int = 4):
    vector = model.encode([query], normalize_embeddings=True)[0].tolist()
    result = collection.query(
        query_embeddings=[vector],
        n_results=top_k,
        where={"$or": [{"product": product}, {"product": "all"}]},
    )
    return [
        {"text": doc, "metadata": meta, "distance": distance}
        for doc, meta, distance in zip(
            result["documents"][0],
            result["metadatas"][0],
            result["distances"][0],
        )
    ]

HIGH_RISK_WORDS = ["赔偿", "退款金额", "投诉", "律师", "起诉", "封号"]
MAX_COSINE_DISTANCE = 1.2

def should_handoff(question: str, docs: list[dict]) -> tuple[bool, str | None]:
    if any(word in question for word in HIGH_RISK_WORDS):
        return True, "问题涉及高风险承诺或投诉,需要人工处理"
    if not docs:
        return True, "没有检索到可用知识"
    if docs[0]["distance"] > MAX_COSINE_DISTANCE:
        return True, "检索相关性不足"
    return False, None

def compose_answer(question: str, docs: list[dict]) -> str:
    top = docs[0]
    return (
        f"根据当前资料,建议回复:\n\n{top['text']}\n\n"
        f"如用户情况不符合上述资料,请转人工确认。\n"
        f"引用来源:{top['metadata']['source']}"
    )

FastAPI

src/app.py

python
from fastapi import FastAPI
from pydantic import BaseModel
from src.retriever import retrieve, should_handoff, compose_answer
from src.schema import Answer

app = FastAPI(title="Customer RAG")

class AskRequest(BaseModel):
    question: str
    product: str = "all"

@app.post("/ask", response_model=Answer)
def ask(req: AskRequest):
    docs = retrieve(req.question, req.product)
    handoff, reason = should_handoff(req.question, docs)
    if handoff:
        return Answer(answer="这个问题需要人工客服进一步确认。", sources=[], handoff=True, reason=reason)
    return Answer(
        answer=compose_answer(req.question, docs),
        sources=[d["metadata"]["source"] for d in docs],
        handoff=False,
    )

启动和测试

bash
uvicorn src.app:app --reload
bash
curl -X POST http://127.0.0.1:8000/ask \
  -H 'Content-Type: application/json' \
  -d '{"question":"耳机进水能保修吗","product":"headphone"}'

再测转人工:

bash
curl -X POST http://127.0.0.1:8000/ask \
  -H 'Content-Type: application/json' \
  -d '{"question":"你们必须赔偿我 500 元"}'

验收点

  • 普通问题返回 handoff: false
  • 高风险问题返回 handoff: true
  • 普通回答包含 sources
  • 回答里有引用来源。

如果返回“检索相关性不足”

先不要急着改 Prompt。这个返回通常说明检索距离超过了阈值。Chroma 的 distance 是距离,不是相似度,数值越小越相关。本教程显式使用 cosine 距离后,可以先按这个范围理解:

  • 小于 0.4:通常很相关。
  • 0.40.9:可能相关,需要看命中文本。
  • 0.91.2:弱相关,小数据集早期可以先放行观察。
  • 大于 1.2:通常应转人工或拒答。

调试时可以临时打印 top 结果:

python
related_docs = retrieve("耳机进水能保修吗", "headphone")
for d in related_docs:
    print(d["distance"], d["metadata"], d["text"][:80])
python
from openai import OpenAI

client = OpenAI(api_key="你的API_KEY")

context = "\n".join(related_docs)

prompt = f"""
请根据以下内容回答问题:

{context}

问题:{query}
"""

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": prompt}]
)

print(response.choices[0].message.content)

如果你已经按旧代码运行过入库脚本,必须删除 data/index 后重新执行 python -m src.ingest,否则旧 collection 仍可能不是 cosine 配置。

实战客服 RAG 04:增加评测脚本和上线检查清单
实战客服 RAG 02:清洗、分块并写入向量库