Init: 第一个版本

This commit is contained in:
2026-10-10 23:11:55 +08:00
commit c1a3461c49
17 changed files with 3806 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
import logging
import httpx
from app.config import LOW_SCORE, llm_config
logger = logging.getLogger("learn_rag")
SYSTEM_PROMPT = """你是「知迹」的知识库问答助手。只能根据给出的知识切片回答,不要补充切片之外的信息,也不要编造。
如果切片不足以回答,就说明知识库里没有这项信息。
用自然的中文直接回答问题,不要使用 Markdown,不要写标题。
引用事实时在相关句末标注编号,格式必须是〔来源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"
user_prompt = f"{caution}知识切片如下:\n\n{build_context(chunks)}\n\n问题:{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 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
)
text = (content or "").strip()
if not text:
raise ValueError("模型返回了空回答")
return text