Files
Learn-LLM-RAG/app/generator.py
T
2026-10-12 11:28:37 +08:00

134 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
import re
import httpx
from app.config import LOW_SCORE, llm_config
logger = logging.getLogger("learn_rag")
# 发给服务器的第一条消息,role = system。
# 这里只有客服身份和回答规则,不含检索结果。换模型时这段一般不用改。
SYSTEM_PROMPT = """你是「知迹」的客服。用友好、耐心、自然的中文和顾客说话,像在柜台前当面回答。
规则:
1. 只能根据本次给出的知识切片回答,不要补充切片之外的信息,不要编造。
2. 先用「您好」一类的问候开场,再回答问题,并补上顾客需要知道的条件。语气像柜台客服,礼貌、清楚,不要只报两个数字。
3. 如果切片不够回答,坦诚说明现在查不到,并建议对方到服务台再确认。
4. 不要使用 Markdown,不要写标题,不要复述全部切片。
5. 引用事实时在相关句末标注〔来源1〕这种编号。编号只能使用给定切片的编号。"""
def build_context(chunks: list[dict]) -> str:
blocks = []
for chunk in chunks:
blocks.append(
f"〔来源{chunk['n']}〕《{chunk['title']}》相关度 {chunk['score']:.0%}\n{chunk['text']}"
)
return "\n\n".join(blocks)
def extractive_answer(chunks: list[dict], low_confidence: bool) -> str:
if not chunks:
return "知识库里还没有可以检索的内容。"
lead = ""
if low_confidence:
lead = "最接近的切片和问题只是部分相关,下面按原文摘出,请以右侧切片为准。\n\n"
lines = [lead + chunks[0]["text"].strip() + f"〔来源{chunks[0]['n']}〕"]
for chunk in chunks[1:2]:
if chunks[0]["score"] - chunk["score"] <= 0.08:
lines.append("\n" + chunk["text"].strip() + f"〔来源{chunk['n']}〕")
return "\n".join(lines).strip()
def generate(question: str, chunks: list[dict]) -> tuple[str, str, str | None, bool]:
low_confidence = not chunks or chunks[0]["score"] < LOW_SCORE
if not chunks:
return "知识库里还没有可以检索的内容。", "extractive", None, True
settings = llm_config()
if settings is None:
notice = "还没有配置大模型,回答直接摘自相关度最高的切片。在项目目录的 .env 中填入 OPENAI_API_KEY 后重启,即可生成连贯回答。"
return extractive_answer(chunks, low_confidence), "extractive", notice, low_confidence
caution = ""
if low_confidence:
caution = "注意:最高相关度偏低。如果切片不能直接支持答案,请坦诚告诉顾客查不到。\n\n"
# 发给服务器的第二条消息,role = user。检索到的切片从这里进提示词。
# 拼出来的样子:
# 下面是检索到的知识切片,请据此回答顾客。
#
# 〔来源1〕《文档标题》相关度 75%
# 切片原文……
#
# 〔来源2〕《文档标题》相关度 71%
# 切片原文……
#
# 顾客的问题:柏卡会员年费多少,能借几本书?
user_prompt = (
f"{caution}下面是检索到的知识切片,请据此回答顾客。\n\n"
f"{build_context(chunks)}\n\n"
f"顾客的问题:{question}"
)
try:
answer = call_llm(settings, user_prompt)
except Exception:
logger.exception("大模型调用失败")
notice = "大模型暂时不可用,已改为直接摘录检索到的切片。"
return extractive_answer(chunks, low_confidence), "extractive", notice, low_confidence
return answer, "llm", None, low_confidence
def strip_think(text: str) -> str:
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
def call_llm(settings: dict, user_prompt: str) -> str:
# 一次请求只生成一次回答。messages 就是上面两条:
# system = 客服身份;user = 知识切片 + 顾客问题。
# 换模型只改 OLLAMA_MODEL(现在是 qwen3-vl:4b),这两条提示词保持不变。
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
]
timeout = httpx.Timeout(180.0, connect=10.0)
if settings["provider"] == "ollama":
# POST {OLLAMA_BASE_URL}/api/chat
# think=false:不要把推理过程写进回答。
response = httpx.post(
f"{settings['base']}/api/chat",
json={
"model": settings["model"],
"messages": messages,
"stream": False,
"think": False,
"options": {"temperature": 0.4, "num_predict": 800},
},
timeout=timeout,
)
response.raise_for_status()
message = response.json().get("message") or {}
text = strip_think(message.get("content") or "")
else:
response = httpx.post(
f"{settings['base']}/chat/completions",
headers={"Authorization": f"Bearer {settings['key']}"},
json={
"model": settings["model"],
"temperature": 0.4,
"max_tokens": 800,
"messages": messages,
},
timeout=timeout,
)
response.raise_for_status()
message = response.json()["choices"][0]["message"]
content = message.get("content")
if isinstance(content, list):
content = "".join(
part.get("text", "") if isinstance(part, dict) else str(part) for part in content
)
text = strip_think(content or "")
if not text:
raise ValueError("模型返回了空回答")
return text