Feat: 调用本地 Ollama
This commit is contained in:
+8
-4
@@ -1,7 +1,11 @@
|
|||||||
# 复制为 .env 后重启服务。留空则只做检索,回答直接摘自最相关切片。
|
# 回答由局域网 Ollama 生成。改地址或模型名后需要重启服务。
|
||||||
OPENAI_API_KEY=
|
OLLAMA_BASE_URL=http://10.7.9.3:11434
|
||||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
OLLAMA_MODEL=qwen3-vl:4b
|
||||||
OPENAI_MODEL=gpt-4o-mini
|
|
||||||
|
# 如果改用 OpenAI 兼容接口,同时填上这两项后会优先使用它。
|
||||||
|
# OPENAI_API_KEY=
|
||||||
|
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
# OPENAI_MODEL=gpt-4o-mini
|
||||||
|
|
||||||
# 可选。嵌入模型默认从 Hugging Face 下载,连不上时会自动改用镜像。
|
# 可选。嵌入模型默认从 Hugging Face 下载,连不上时会自动改用镜像。
|
||||||
# HF_ENDPOINT=https://hf-mirror.com
|
# HF_ENDPOINT=https://hf-mirror.com
|
||||||
|
|||||||
+9
-8
@@ -36,12 +36,13 @@ load_dotenv()
|
|||||||
|
|
||||||
def llm_config() -> dict | None:
|
def llm_config() -> dict | None:
|
||||||
key = os.getenv("OPENAI_API_KEY", "").strip()
|
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
|
return None
|
||||||
base = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1").strip()
|
model = os.getenv("OLLAMA_MODEL", "qwen3-vl:4b").strip() or "qwen3-vl:4b"
|
||||||
model = os.getenv("OPENAI_MODEL", "gpt-4o-mini").strip()
|
return {"provider": "ollama", "key": "ollama", "base": base.rstrip("/"), "model": model}
|
||||||
return {
|
|
||||||
"key": key,
|
|
||||||
"base": (base or "https://api.openai.com/v1").rstrip("/"),
|
|
||||||
"model": model or "gpt-4o-mini",
|
|
||||||
}
|
|
||||||
|
|||||||
+75
-27
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -6,10 +7,15 @@ from app.config import LOW_SCORE, llm_config
|
|||||||
|
|
||||||
logger = logging.getLogger("learn_rag")
|
logger = logging.getLogger("learn_rag")
|
||||||
|
|
||||||
SYSTEM_PROMPT = """你是「知迹」的知识库问答助手。只能根据给出的知识切片回答,不要补充切片之外的信息,也不要编造。
|
# 发给服务器的第一条消息,role = system。
|
||||||
如果切片不足以回答,就说明知识库里没有这项信息。
|
# 这里只有客服身份和回答规则,不含检索结果。换模型时这段一般不用改。
|
||||||
用自然的中文直接回答问题,不要使用 Markdown,不要写标题。
|
SYSTEM_PROMPT = """你是「知迹」的客服。用友好、耐心、自然的中文和顾客说话,像在柜台前当面回答。
|
||||||
引用事实时在相关句末标注编号,格式必须是〔来源1〕这种全角括号。编号只能使用给定切片的编号。"""
|
规则:
|
||||||
|
1. 只能根据本次给出的知识切片回答,不要补充切片之外的信息,不要编造。
|
||||||
|
2. 先用「您好」一类的问候开场,再回答问题,并补上顾客需要知道的条件。语气像柜台客服,礼貌、清楚,不要只报两个数字。
|
||||||
|
3. 如果切片不够回答,坦诚说明现在查不到,并建议对方到服务台再确认。
|
||||||
|
4. 不要使用 Markdown,不要写标题,不要复述全部切片。
|
||||||
|
5. 引用事实时在相关句末标注〔来源1〕这种编号。编号只能使用给定切片的编号。"""
|
||||||
|
|
||||||
|
|
||||||
def build_context(chunks: list[dict]) -> str:
|
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 = ""
|
caution = ""
|
||||||
if low_confidence:
|
if low_confidence:
|
||||||
caution = "注意:最高相关度偏低。如果切片不能直接支持答案,请明确说知识库里没有这项信息。\n\n"
|
caution = "注意:最高相关度偏低。如果切片不能直接支持答案,请坦诚告诉顾客查不到。\n\n"
|
||||||
user_prompt = f"{caution}知识切片如下:\n\n{build_context(chunks)}\n\n问题:{question}"
|
# 发给服务器的第二条消息,role = user。检索到的切片从这里进提示词。
|
||||||
|
# 拼出来的样子:
|
||||||
|
# 下面是检索到的知识切片,请据此回答顾客。
|
||||||
|
#
|
||||||
|
# 〔来源1〕《文档标题》相关度 75%
|
||||||
|
# 切片原文……
|
||||||
|
#
|
||||||
|
# 〔来源2〕《文档标题》相关度 71%
|
||||||
|
# 切片原文……
|
||||||
|
#
|
||||||
|
# 顾客的问题:柏卡会员年费多少,能借几本书?
|
||||||
|
user_prompt = (
|
||||||
|
f"{caution}下面是检索到的知识切片,请据此回答顾客。\n\n"
|
||||||
|
f"{build_context(chunks)}\n\n"
|
||||||
|
f"顾客的问题:{question}"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
answer = call_llm(settings, user_prompt)
|
answer = call_llm(settings, user_prompt)
|
||||||
except Exception:
|
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
|
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:
|
def call_llm(settings: dict, user_prompt: str) -> str:
|
||||||
response = httpx.post(
|
# 一次请求只生成一次回答。messages 就是上面两条:
|
||||||
f"{settings['base']}/chat/completions",
|
# system = 客服身份;user = 知识切片 + 顾客问题。
|
||||||
headers={"Authorization": f"Bearer {settings['key']}"},
|
# 换模型只改 OLLAMA_MODEL(现在是 qwen3-vl:4b),这两条提示词保持不变。
|
||||||
json={
|
messages = [
|
||||||
"model": settings["model"],
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
"temperature": 0.2,
|
{"role": "user", "content": user_prompt},
|
||||||
"max_tokens": 800,
|
]
|
||||||
"messages": [
|
timeout = httpx.Timeout(180.0, connect=10.0)
|
||||||
{"role": "system", "content": SYSTEM_PROMPT},
|
if settings["provider"] == "ollama":
|
||||||
{"role": "user", "content": user_prompt},
|
# POST {OLLAMA_BASE_URL}/api/chat
|
||||||
],
|
# think=false:不要把推理过程写进回答。
|
||||||
},
|
response = httpx.post(
|
||||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
f"{settings['base']}/api/chat",
|
||||||
)
|
json={
|
||||||
response.raise_for_status()
|
"model": settings["model"],
|
||||||
message = response.json()["choices"][0]["message"]
|
"messages": messages,
|
||||||
content = message.get("content")
|
"stream": False,
|
||||||
if isinstance(content, list):
|
"think": False,
|
||||||
content = "".join(
|
"options": {"temperature": 0.4, "num_predict": 800},
|
||||||
part.get("text", "") if isinstance(part, dict) else str(part) for part in content
|
},
|
||||||
|
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:
|
if not text:
|
||||||
raise ValueError("模型返回了空回答")
|
raise ValueError("模型返回了空回答")
|
||||||
return text
|
return text
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ def status(request: Request) -> dict:
|
|||||||
"chunk_overlap": CHUNK_OVERLAP,
|
"chunk_overlap": CHUNK_OVERLAP,
|
||||||
"llm_ready": llm is not None,
|
"llm_ready": llm is not None,
|
||||||
"llm_model": llm["model"] if llm else None,
|
"llm_model": llm["model"] if llm else None,
|
||||||
|
"llm_provider": llm["provider"] if llm else None,
|
||||||
"documents": documents,
|
"documents": documents,
|
||||||
"chunks": chunks,
|
"chunks": chunks,
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -125,7 +125,7 @@ function setBusy(busy) {
|
|||||||
docContent.disabled = !state.ready;
|
docContent.disabled = !state.ready;
|
||||||
saveDoc.disabled = !state.ready;
|
saveDoc.disabled = !state.ready;
|
||||||
pickFile.disabled = !state.ready;
|
pickFile.disabled = !state.ready;
|
||||||
sendButton.textContent = busy ? "检索中" : "提问";
|
sendButton.textContent = busy ? "回答中" : "提问";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStatus(data) {
|
function renderStatus(data) {
|
||||||
@@ -148,7 +148,8 @@ function renderStatus(data) {
|
|||||||
const llm = el("span", "pill");
|
const llm = el("span", "pill");
|
||||||
if (data.llm_ready) {
|
if (data.llm_ready) {
|
||||||
llm.classList.add("is-ok");
|
llm.classList.add("is-ok");
|
||||||
llm.textContent = `生成模型 ${data.llm_model}`;
|
const where = data.llm_provider === "ollama" ? "局域网 Ollama" : "生成模型";
|
||||||
|
llm.textContent = `${where} · ${data.llm_model}`;
|
||||||
} else {
|
} else {
|
||||||
llm.textContent = "未配置大模型,先摘录原文";
|
llm.textContent = "未配置大模型,先摘录原文";
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>知迹 · 检索问答</title>
|
<title>知迹 · 检索问答</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect fill='%231f6a52' width='32' height='32' rx='6'/%3E%3Cpath d='M8 9.5h7.2c1.6 0 2.8 1 2.8 2.6S16.8 15 15.2 15H8V9.5zm0 7h7.6c1.8 0 3.1 1.1 3.1 2.8S17.4 22 15.6 22H8v-5.5z' fill='%23f6f1e6'/%3E%3C/svg%3E" />
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect fill='%231f6a52' width='32' height='32' rx='6'/%3E%3Cpath d='M8 9.5h7.2c1.6 0 2.8 1 2.8 2.6S16.8 15 15.2 15H8V9.5zm0 7h7.6c1.8 0 3.1 1.1 3.1 2.8S17.4 22 15.6 22H8v-5.5z' fill='%23f6f1e6'/%3E%3C/svg%3E" />
|
||||||
<link rel="stylesheet" href="/static/styles.css?v=1" />
|
<link rel="stylesheet" href="/static/styles.css?v=2" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
@@ -73,6 +73,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="toast" class="toast" role="status"></div>
|
<div id="toast" class="toast" role="status"></div>
|
||||||
<script src="/static/app.js?v=1"></script>
|
<script src="/static/app.js?v=2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user