diff --git a/.env.example b/.env.example index f85b6e9..1d7216f 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,11 @@ -# 复制为 .env 后重启服务。留空则只做检索,回答直接摘自最相关切片。 -OPENAI_API_KEY= -OPENAI_BASE_URL=https://api.openai.com/v1 -OPENAI_MODEL=gpt-4o-mini +# 回答由局域网 Ollama 生成。改地址或模型名后需要重启服务。 +OLLAMA_BASE_URL=http://10.7.9.3:11434 +OLLAMA_MODEL=qwen3-vl:4b + +# 如果改用 OpenAI 兼容接口,同时填上这两项后会优先使用它。 +# OPENAI_API_KEY= +# OPENAI_BASE_URL=https://api.openai.com/v1 +# OPENAI_MODEL=gpt-4o-mini # 可选。嵌入模型默认从 Hugging Face 下载,连不上时会自动改用镜像。 # HF_ENDPOINT=https://hf-mirror.com diff --git a/app/config.py b/app/config.py index e2a443f..42a3d16 100644 --- a/app/config.py +++ b/app/config.py @@ -36,12 +36,13 @@ load_dotenv() def llm_config() -> dict | None: key = os.getenv("OPENAI_API_KEY", "").strip() - if not key: + openai_base = os.getenv("OPENAI_BASE_URL", "").strip() + if key and openai_base: + model = os.getenv("OPENAI_MODEL", "gpt-4o-mini").strip() or "gpt-4o-mini" + return {"provider": "openai", "key": key, "base": openai_base.rstrip("/"), "model": model} + + base = os.getenv("OLLAMA_BASE_URL", "http://10.7.9.3:11434").strip() + if not base: return None - base = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1").strip() - model = os.getenv("OPENAI_MODEL", "gpt-4o-mini").strip() - return { - "key": key, - "base": (base or "https://api.openai.com/v1").rstrip("/"), - "model": model or "gpt-4o-mini", - } + model = os.getenv("OLLAMA_MODEL", "qwen3-vl:4b").strip() or "qwen3-vl:4b" + return {"provider": "ollama", "key": "ollama", "base": base.rstrip("/"), "model": model} diff --git a/app/generator.py b/app/generator.py index 169b7c5..79dd230 100644 --- a/app/generator.py +++ b/app/generator.py @@ -1,4 +1,5 @@ import logging +import re import httpx @@ -6,10 +7,15 @@ from app.config import LOW_SCORE, llm_config logger = logging.getLogger("learn_rag") -SYSTEM_PROMPT = """你是「知迹」的知识库问答助手。只能根据给出的知识切片回答,不要补充切片之外的信息,也不要编造。 -如果切片不足以回答,就说明知识库里没有这项信息。 -用自然的中文直接回答问题,不要使用 Markdown,不要写标题。 -引用事实时在相关句末标注编号,格式必须是〔来源1〕这种全角括号。编号只能使用给定切片的编号。""" +# 发给服务器的第一条消息,role = system。 +# 这里只有客服身份和回答规则,不含检索结果。换模型时这段一般不用改。 +SYSTEM_PROMPT = """你是「知迹」的客服。用友好、耐心、自然的中文和顾客说话,像在柜台前当面回答。 +规则: +1. 只能根据本次给出的知识切片回答,不要补充切片之外的信息,不要编造。 +2. 先用「您好」一类的问候开场,再回答问题,并补上顾客需要知道的条件。语气像柜台客服,礼貌、清楚,不要只报两个数字。 +3. 如果切片不够回答,坦诚说明现在查不到,并建议对方到服务台再确认。 +4. 不要使用 Markdown,不要写标题,不要复述全部切片。 +5. 引用事实时在相关句末标注〔来源1〕这种编号。编号只能使用给定切片的编号。""" def build_context(chunks: list[dict]) -> str: @@ -46,8 +52,23 @@ def generate(question: str, chunks: list[dict]) -> tuple[str, str, str | None, b caution = "" if low_confidence: - caution = "注意:最高相关度偏低。如果切片不能直接支持答案,请明确说知识库里没有这项信息。\n\n" - user_prompt = f"{caution}知识切片如下:\n\n{build_context(chunks)}\n\n问题:{question}" + 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: @@ -57,29 +78,56 @@ def generate(question: str, chunks: list[dict]) -> tuple[str, str, str | None, b return answer, "llm", None, low_confidence +def strip_think(text: str) -> str: + return re.sub(r".*?", "", text, flags=re.DOTALL).strip() + + def call_llm(settings: dict, user_prompt: str) -> str: - response = httpx.post( - f"{settings['base']}/chat/completions", - headers={"Authorization": f"Bearer {settings['key']}"}, - json={ - "model": settings["model"], - "temperature": 0.2, - "max_tokens": 800, - "messages": [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": user_prompt}, - ], - }, - timeout=httpx.Timeout(60.0, connect=10.0), - ) - 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 + # 一次请求只生成一次回答。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, ) - text = (content or "").strip() + 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 diff --git a/app/main.py b/app/main.py index ec64695..f9fede8 100644 --- a/app/main.py +++ b/app/main.py @@ -127,6 +127,7 @@ def status(request: Request) -> dict: "chunk_overlap": CHUNK_OVERLAP, "llm_ready": llm is not None, "llm_model": llm["model"] if llm else None, + "llm_provider": llm["provider"] if llm else None, "documents": documents, "chunks": chunks, } diff --git a/static/app.js b/static/app.js index d7d80f7..3e137db 100644 --- a/static/app.js +++ b/static/app.js @@ -125,7 +125,7 @@ function setBusy(busy) { docContent.disabled = !state.ready; saveDoc.disabled = !state.ready; pickFile.disabled = !state.ready; - sendButton.textContent = busy ? "检索中" : "提问"; + sendButton.textContent = busy ? "回答中" : "提问"; } function renderStatus(data) { @@ -148,7 +148,8 @@ function renderStatus(data) { const llm = el("span", "pill"); if (data.llm_ready) { llm.classList.add("is-ok"); - llm.textContent = `生成模型 ${data.llm_model}`; + const where = data.llm_provider === "ollama" ? "局域网 Ollama" : "生成模型"; + llm.textContent = `${where} · ${data.llm_model}`; } else { llm.textContent = "未配置大模型,先摘录原文"; } diff --git a/static/index.html b/static/index.html index e67db02..2def36d 100644 --- a/static/index.html +++ b/static/index.html @@ -5,7 +5,7 @@ 知迹 · 检索问答 - +
@@ -73,6 +73,6 @@
- +