fix: 1. 增加简易后台页面及相关接口 2.兼容vllm思考模式 3.rerank方法问题处理 4.增加余弦参数配置,解决幻觉问题 5.临时停用VL

This commit is contained in:
fuzhongyun 2026-01-19 14:56:03 +08:00
parent 75044940c7
commit ccf8bd6902
7 changed files with 569 additions and 17 deletions

View File

@ -33,4 +33,5 @@ DATA_DIR=./index_data
# RAG Configuration
EMBEDDING_DIM=1024
MAX_TOKEN_SIZE=8192
MAX_RAG_INSTANCES=5 # 最大活跃 RAG 实例数
MAX_RAG_INSTANCES=5 # 最大活跃 RAG 实例数
COSINE_THRESHOLD=0.4 # 余弦相似度阈值

View File

@ -48,6 +48,53 @@ class QAPair(BaseModel):
# 接口实现
# ==========================================
import secrets
import string
@router.get("/admin/tenants")
async def list_tenants(token: str):
"""
管理员接口获取租户列表
"""
if token != settings.ADMIN_TOKEN:
raise HTTPException(status_code=403, detail="Invalid admin token")
try:
if not os.path.exists(settings.DATA_DIR):
return {"tenants": []}
tenants = []
for entry in os.scandir(settings.DATA_DIR):
if entry.is_dir() and not entry.name.startswith("."):
tenant_id = entry.name
secret_file = os.path.join(entry.path, ".secret")
# 读取或生成租户专属 Secret
if os.path.exists(secret_file):
with open(secret_file, "r") as f:
secret = f.read().strip()
else:
# 生成16位随机字符串
alphabet = string.ascii_letters + string.digits
secret = ''.join(secrets.choice(alphabet) for i in range(16))
try:
with open(secret_file, "w") as f:
f.write(secret)
except Exception as e:
logging.error(f"Failed to write secret for tenant {tenant_id}: {e}")
continue
# 生成租户访问 Token (租户ID_随机串)
tenant_token = f"{tenant_id}_{secret}"
tenants.append({
"id": tenant_id,
"token": tenant_token
})
return {"tenants": tenants}
except Exception as e:
logging.error(f"Failed to list tenants: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/health")
async def health_check():
"""健康检查接口"""
@ -63,6 +110,7 @@ async def query_knowledge_base(
- query: 用户问题
- mode: 检索模式 (推荐 hybrid 用于事实类查询)
- stream: 是否流式输出 (默认 False)
- think: 是否启用思考模式 (默认 False)
"""
try:
# 构造查询参数
@ -93,12 +141,24 @@ async def query_knowledge_base(
# 获取上下文 (这步耗时较长,包含图遍历)
context_resp = await rag.aquery(request.query, param=context_param)
logging.info(f"Context Response: {context_resp}")
# 简单判断是否找到内容
if not context_resp or "Sorry, I'm not able to answer" in context_resp:
yield sse_pack("thinking", " (未找到相关上下文,将依赖 LLM 自身知识)")
else:
# 判断检索状态
has_context = False
if context_resp and "[no-context]" not in context_resp and "None" not in context_resp:
has_context = True
# 判断是否开启think
think = request.think
if has_context:
yield sse_pack("system", "retrieved") # 发送系统事件:已检索到信息
yield sse_pack("thinking", f"2. 上下文已检索 (长度: {len(context_resp)} 字符).\n")
else:
yield sse_pack("system", "missed") # 发送系统事件:未检索到信息
yield sse_pack("thinking", "2. 未找到相关上下文,将依赖 LLM 自身知识\n")
think = False
yield sse_pack("thinking", "3. 答案生成中...\n")
@ -115,7 +175,7 @@ async def query_knowledge_base(
request.query,
system_prompt=sys_prompt,
stream=True,
think=request.think,
think=think,
hashing_kv=rag.llm_response_cache
)

View File

@ -38,6 +38,10 @@ class Settings(BaseSettings):
EMBEDDING_DIM: int = 1024
MAX_TOKEN_SIZE: int = 8192
MAX_RAG_INSTANCES: int = 3 # 最大活跃 RAG 实例数
COSINE_THRESHOLD: float = 0.4 # 向量检索相似度阈值
# Admin & Security
ADMIN_TOKEN: str = "fzy"
class Config:
env_file = ".env"

View File

@ -70,7 +70,7 @@ async def process_pdf_with_images(file_bytes: bytes) -> str:
text_content += f"--- Page {page_num + 1} Text ---\n{page_text}\n\n"
# 2. 提取图片
if settings.VL_BINDING_HOST:
if False and settings.VL_BINDING_HOST:
for count, image_file_object in enumerate(page.images):
try:
# 获取图片数据

View File

@ -68,7 +68,9 @@ async def openai_llm_func(prompt, system_prompt=None, history_messages=[], **kwa
stream = kwargs.pop("stream", False)
# think 参数是 DeepSeek 特有的OpenAI 标准接口不支持,暂时忽略
kwargs.pop("think", None)
think = kwargs.pop("think", None)
# 这里使用qwen3指定的chat_template_kwargs开启/禁用思考模式
kwargs["chat_template_kwargs"] = {"enable_thinking": think}
messages = []
if system_prompt:
@ -169,16 +171,17 @@ async def embedding_func(texts: list[str]) -> np.ndarray:
# Rerank Functions
# ==============================================================================
async def tei_rerank_func(query: str, documents: list[str]) -> np.ndarray:
async def tei_rerank_func(query: str, documents: list[str], top_n: int = 10) -> list[dict]:
"""TEI Rerank 实现"""
if not documents:
return np.array([])
return []
url = f"{settings.RERANK_BINDING_HOST}/rerank"
headers = {"Content-Type": "application/json"}
if settings.RERANK_KEY and settings.RERANK_KEY != "EMPTY":
headers["Authorization"] = f"Bearer {settings.RERANK_KEY}"
# TEI 不支持 top_n 参数,我们手动截断或忽略
payload = {
"query": query,
"texts": documents,
@ -191,14 +194,16 @@ async def tei_rerank_func(query: str, documents: list[str]) -> np.ndarray:
# TEI 返回: [{"index": 0, "score": 0.99}, {"index": 1, "score": 0.5}]
results = response.json()
# LightRAG 期望返回一个分数数组,对应输入的 documents 顺序
# TEI 返回的结果是排序过的,我们需要根据 index 还原顺
scores = np.zeros(len(documents))
# LightRAG 期望返回包含 index 和 relevance_score 的字典列表
# 这样 LightRAG 才能正确映射回原始文档并进行排
formatted_results = []
for res in results:
idx = res['index']
scores[idx] = res['score']
formatted_results.append({
"index": res['index'],
"relevance_score": res['score']
})
return scores
return formatted_results
# ==============================================================================
# RAG Manager
@ -237,7 +242,8 @@ class RAGManager:
func=embedding_func
),
"embedding_func_max_async": 8, # TEI 并发强
"enable_llm_cache": True
"enable_llm_cache": True,
"cosine_threshold": settings.COSINE_THRESHOLD
}
# 如果启用了 Rerank注入 rerank_model_func

View File

@ -1,10 +1,12 @@
import logging
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
from app.config import settings
from app.core.rag import initialize_rag_manager
from app.core.prompts import patch_prompts
from app.api.routes import router
import os
# 配置日志
logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.INFO)
@ -26,6 +28,14 @@ app = FastAPI(
lifespan=lifespan
)
# 确保静态目录存在
static_dir = os.path.join(os.path.dirname(__file__), "static")
if not os.path.exists(static_dir):
os.makedirs(static_dir)
# 挂载静态文件
app.mount("/static", StaticFiles(directory=static_dir), name="static")
app.include_router(router)
if __name__ == "__main__":

471
app/static/admin.html Normal file
View File

@ -0,0 +1,471 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LightRAG 管理后台</title>
<!-- Vue 3 -->
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
<!-- Element Plus -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-plus/dist/index.css" />
<script src="https://cdn.jsdelivr.net/npm/element-plus"></script>
<!-- Element Plus Icons -->
<script src="https://cdn.jsdelivr.net/npm/@element-plus/icons-vue"></script>
<!-- Axios -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<!-- Marked -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
body { margin: 0; padding: 0; font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif; background-color: #f5f7fa; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.header { background: #fff; padding: 20px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1); margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; }
.header h1 { margin: 0; font-size: 24px; color: #409EFF; }
.card { background: #fff; border-radius: 4px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
.chat-box { height: 500px; overflow-y: auto; border: 1px solid #EBEEF5; padding: 20px; border-radius: 4px; margin-bottom: 20px; background: #fafafa; }
.message { margin-bottom: 15px; display: flex; }
.message.user { justify-content: flex-end; }
.message.assistant { justify-content: flex-start; }
.message-content { max-width: 80%; padding: 10px 15px; border-radius: 4px; font-size: 14px; line-height: 1.6; }
.message.user .message-content { background: #409EFF; color: #fff; }
.message.assistant .message-content { background: #fff; border: 1px solid #EBEEF5; }
.markdown-body { font-size: 14px; }
.markdown-body pre { background: #f6f8fa; padding: 10px; border-radius: 4px; overflow-x: auto; }
</style>
</head>
<body>
<div id="app">
<div class="header">
<h1 @click="goHome" style="cursor: pointer;">LightRAG Admin</h1>
<div v-if="currentPage === 'tenant'">
<el-tag type="success" size="large">当前租户: {{ currentTenantId }}</el-tag>
<el-button type="primary" link @click="goHome">返回首页</el-button>
</div>
</div>
<div class="container">
<!-- 首页:租户列表 -->
<div v-if="currentPage === 'home'">
<el-row :gutter="20">
<el-col :span="24">
<div class="card">
<div style="display: flex; justify-content: space-between; margin-bottom: 20px;">
<h3>租户列表</h3>
<el-button type="primary" @click="refreshTenants">刷新</el-button>
</div>
<el-table :data="tenants" stripe style="width: 100%">
<el-table-column prop="id" label="租户ID"></el-table-column>
<el-table-column label="操作" width="180">
<template #default="scope">
<el-button type="primary" size="small" @click="enterTenant(scope.row)">管理文档</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-col>
</el-row>
</div>
<!-- 租户页:文档管理与问答 -->
<div v-if="currentPage === 'tenant'">
<el-tabs v-model="activeTab" class="card">
<!-- 文档管理 Tab -->
<el-tab-pane label="文档管理" name="docs">
<div style="margin-bottom: 20px;">
<el-button type="primary" @click="showImportDialog = true">导入文档/QA</el-button>
<el-button @click="fetchDocuments">刷新列表</el-button>
</div>
<el-table :data="documents" v-loading="loadingDocs" style="width: 100%">
<el-table-column prop="id" label="文档ID" width="220"></el-table-column>
<el-table-column prop="summary" label="摘要" show-overflow-tooltip></el-table-column>
<el-table-column prop="length" label="长度" width="100"></el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="scope">
{{ formatDate(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="scope">
<el-button size="small" @click="viewDocument(scope.row.id)">详情</el-button>
<el-popconfirm title="确定删除吗?" @confirm="deleteDocument(scope.row.id)">
<template #reference>
<el-button size="small" type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 知识检索 Tab -->
<el-tab-pane label="知识检索" name="chat">
<div class="chat-box" ref="chatBox">
<div v-for="(msg, index) in chatHistory" :key="index" :class="['message', msg.role]">
<div class="message-content markdown-body" v-html="renderMarkdown(msg.content, msg.thinking, msg.retrievalStatus)"></div>
</div>
</div>
<div style="display: flex; gap: 10px;">
<el-input v-model="queryInput" placeholder="请输入问题..." @keyup.enter="sendQuery"></el-input>
<el-button type="primary" :loading="chatLoading" @click="sendQuery">发送</el-button>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
<!-- 导入弹窗 -->
<el-dialog v-model="showImportDialog" title="导入知识" width="600px">
<el-tabs v-model="importType">
<el-tab-pane label="文件上传" name="file">
<el-upload
class="upload-demo"
drag
action="#"
:http-request="uploadFile"
:show-file-list="false"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">拖拽文件到此处或 <em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">支持 .txt, .md, .pdf</div>
</template>
</el-upload>
</el-tab-pane>
<el-tab-pane label="纯文本" name="text">
<el-input v-model="importText" type="textarea" rows="10" placeholder="请输入文本内容..."></el-input>
<div style="margin-top: 10px; text-align: right;">
<el-button type="primary" @click="uploadText">提交</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="QA 问答" name="qa">
<div v-for="(qa, index) in qaList" :key="index" style="margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 15px;">
<el-input v-model="qa.question" placeholder="问题 (Question)" style="margin-bottom: 5px;"></el-input>
<el-input v-model="qa.answer" type="textarea" placeholder="回答 (Answer)"></el-input>
<el-button type="danger" link size="small" @click="removeQA(index)" v-if="qaList.length > 1">删除</el-button>
</div>
<el-button type="primary" plain style="width: 100%; margin-bottom: 10px;" @click="addQA">添加一组 QA</el-button>
<div style="text-align: right;">
<el-button type="primary" @click="uploadQA">提交所有 QA</el-button>
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
<!-- 文档详情弹窗 -->
<el-dialog v-model="showDocDialog" title="文档详情" width="800px">
<div v-loading="docDetailLoading">
<pre style="white-space: pre-wrap; background: #f5f7fa; padding: 15px; border-radius: 4px; max-height: 500px; overflow-y: auto;">{{ currentDocContent }}</pre>
</div>
<template #footer>
<span class="dialog-footer">
<el-popconfirm title="确定删除此文档吗?" @confirm="deleteCurrentDoc">
<template #reference>
<el-button type="danger">删除文档</el-button>
</template>
</el-popconfirm>
<el-button @click="showDocDialog = false">关闭</el-button>
</span>
</template>
</el-dialog>
</div>
<script>
const { createApp, ref, onMounted, computed } = Vue;
const { ElMessage } = ElementPlus;
const app = createApp({
setup() {
// 状态
const currentPage = ref('home');
const tenants = ref([]);
const currentTenantId = ref('');
const currentToken = ref('');
// 文档管理
const activeTab = ref('docs');
const documents = ref([]);
const loadingDocs = ref(false);
const showImportDialog = ref(false);
const importType = ref('file');
const importText = ref('');
const qaList = ref([{ question: '', answer: '' }]);
// 文档详情
const showDocDialog = ref(false);
const currentDocId = ref('');
const currentDocContent = ref('');
const docDetailLoading = ref(false);
// 聊天
const queryInput = ref('');
const chatHistory = ref([]);
const chatLoading = ref(false);
const chatBox = ref(null);
// API Base
const api = axios.create({ baseURL: '/' });
// 解析 URL 参数
const urlParams = new URLSearchParams(window.location.search);
const tokenParam = urlParams.get('token');
const tenantParam = urlParams.get('tenant_id');
// 初始化
onMounted(() => {
if (tenantParam && tokenParam) {
// 租户模式
if (!tokenParam.startsWith(tenantParam + '_')) {
ElMessage.error('Token 不匹配');
return;
}
enterTenant({ id: tenantParam, token: tokenParam }, false);
} else if (tokenParam === 'fzy') {
// 管理员模式
currentPage.value = 'home';
refreshTenants();
} else {
ElMessage.warning('请在 URL 中提供有效的 token');
}
});
// 方法
const goHome = () => {
window.location.href = '?token=fzy';
};
const refreshTenants = async () => {
try {
const res = await api.get('/admin/tenants', { params: { token: 'fzy' } });
tenants.value = res.data.tenants;
} catch (e) {
ElMessage.error('获取租户列表失败');
}
};
const enterTenant = (tenant, updateUrl = true) => {
currentTenantId.value = tenant.id;
currentToken.value = tenant.token;
currentPage.value = 'tenant';
// 设置 Header
api.defaults.headers.common['X-Tenant-ID'] = tenant.id;
if (updateUrl) {
const newUrl = `${window.location.pathname}?page=tenant&tenant_id=${tenant.id}&token=${tenant.token}`;
window.history.pushState({}, '', newUrl);
}
fetchDocuments();
};
const fetchDocuments = async () => {
loadingDocs.value = true;
try {
const res = await api.get('/documents');
documents.value = res.data.docs;
} catch (e) {
ElMessage.error('获取文档失败');
} finally {
loadingDocs.value = false;
}
};
const deleteDocument = async (id) => {
try {
await api.delete(`/docs/${id}`);
ElMessage.success('删除成功');
fetchDocuments();
showDocDialog.value = false;
} catch (e) {
ElMessage.error('删除失败');
}
};
const viewDocument = async (id) => {
currentDocId.value = id;
showDocDialog.value = true;
docDetailLoading.value = true;
try {
const res = await api.get(`/documents/${id}`);
currentDocContent.value = res.data.content;
} catch (e) {
currentDocContent.value = '加载失败';
} finally {
docDetailLoading.value = false;
}
};
const deleteCurrentDoc = () => {
deleteDocument(currentDocId.value);
};
// 导入逻辑
const uploadFile = async (param) => {
const formData = new FormData();
formData.append('file', param.file);
try {
await api.post('/ingest/file', formData);
ElMessage.success('上传成功');
showImportDialog.value = false;
fetchDocuments();
} catch (e) {
ElMessage.error('上传失败');
}
};
const uploadText = async () => {
if (!importText.value) return;
try {
await api.post('/ingest/text', { text: importText.value });
ElMessage.success('导入成功');
showImportDialog.value = false;
importText.value = '';
fetchDocuments();
} catch (e) {
ElMessage.error('导入失败');
}
};
const addQA = () => qaList.value.push({ question: '', answer: '' });
const removeQA = (idx) => qaList.value.splice(idx, 1);
const uploadQA = async () => {
const validQA = qaList.value.filter(q => q.question && q.answer);
if (validQA.length === 0) return ElMessage.warning('请填写 QA');
try {
await api.post('/ingest/batch_qa', validQA);
ElMessage.success(`成功导入 ${validQA.length} 条 QA`);
showImportDialog.value = false;
qaList.value = [{ question: '', answer: '' }];
fetchDocuments();
} catch (e) {
ElMessage.error('导入失败');
}
};
// 聊天逻辑
const sendQuery = async () => {
if (!queryInput.value.trim()) return;
const q = queryInput.value;
chatHistory.value.push({ role: 'user', content: q });
queryInput.value = '';
chatLoading.value = true;
try {
// 使用流式
const response = await fetch('/query', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': currentTenantId.value
},
body: JSON.stringify({ query: q, stream: true, mode: 'mix', think: true })
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
// 创建消息对象并加入数组
chatHistory.value.push({ role: 'assistant', content: '', thinking: '', retrievalStatus: null });
// 获取响应式对象 (Proxy) 以便更新触发视图渲染
const assistantMsg = chatHistory.value[chatHistory.value.length - 1];
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split('\n\n');
buffer = blocks.pop(); // 保留最后一个可能不完整的块
for (const block of blocks) {
const lines = block.split('\n');
let eventType = 'answer';
let dataText = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
dataText = data.text;
} catch (e) {}
}
}
if (eventType === 'system') {
assistantMsg.retrievalStatus = dataText;
} else if (dataText) {
if (eventType === 'thinking') {
assistantMsg.thinking += dataText;
} else if (eventType === 'answer') {
assistantMsg.content += dataText;
}
// 滚动到底部
if (chatBox.value) chatBox.value.scrollTop = chatBox.value.scrollHeight;
}
}
}
} catch (e) {
chatHistory.value.push({ role: 'assistant', content: '❌ 请求出错: ' + e.message });
} finally {
chatLoading.value = false;
}
};
const renderMarkdown = (content, thinking, retrievalStatus) => {
let html = '';
if (retrievalStatus) {
const color = retrievalStatus === 'retrieved' ? '#67c23a' : '#e6a23c';
const text = retrievalStatus === 'retrieved' ? '已检索到相关知识' : '未检索到相关知识,使用通用知识回答';
const icon = retrievalStatus === 'retrieved' ? '✔️' : '⚠️';
html += `<div style="margin-bottom: 8px; font-size: 12px; color: ${color}; font-weight: bold;">${icon} ${text}</div>`;
}
if (thinking) {
html += `<div class="thinking-box" style="background: #f0f9eb; padding: 10px; border-radius: 4px; margin-bottom: 10px; border: 1px solid #e1f3d8; color: #67c23a; white-space: pre-wrap; font-family: monospace; font-size: 12px;">${thinking}</div>`;
}
html += marked.parse(content || '');
return html;
};
const formatDate = (dateStr) => {
if (!dateStr) return '-';
try {
const date = new Date(dateStr);
return isNaN(date.getTime()) ? dateStr : date.toLocaleString();
} catch (e) {
return dateStr;
}
};
return {
currentPage, tenants, currentTenantId,
activeTab, documents, loadingDocs,
showImportDialog, importType, importText, qaList,
showDocDialog, currentDocId, currentDocContent, docDetailLoading,
queryInput, chatHistory, chatLoading, chatBox,
goHome, refreshTenants, enterTenant, fetchDocuments,
viewDocument, deleteDocument, deleteCurrentDoc,
uploadFile, uploadText, addQA, removeQA, uploadQA,
sendQuery, renderMarkdown, formatDate
};
}
});
// 注册图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus);
app.mount('#app');
</script>
<!-- 引入 Element Plus 图标 -->
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
</body>
</html>