feat: RAG search frontend — search.ui, search_result.dspy, kb_buttons.dspy
This commit is contained in:
parent
2e1c01a036
commit
6f3858a8f2
18
wwwroot/rag/kb_buttons.dspy
Normal file
18
wwwroot/rag/kb_buttons.dspy
Normal file
@ -0,0 +1,18 @@
|
||||
"""返回知识库选择按钮列表"""
|
||||
env = request._run_ns
|
||||
userorgid = await env.get_userorgid()
|
||||
async with get_sor_context(env, 'rag') as sor:
|
||||
sql = "SELECT id, name FROM knowledge_bases WHERE org_id IS NULL OR org_id=${org_id}$ ORDER BY name"
|
||||
recs = await sor.sqlExe(sql, {"org_id": userorgid})
|
||||
|
||||
buttons = []
|
||||
for r in recs:
|
||||
buttons.append({
|
||||
"widgettype": "Text",
|
||||
"options": {"text": str(r.name), "cfontsize": 12, "padding": "4px 12px",
|
||||
"bgcolor": "#eee", "color": "#333", "borderRadius": "4px", "css": "clickable kb-btn"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
|
||||
"script": f"var f=document.querySelector('[name=kb_id]');if(f)f.value='{r.id}';var btns=document.querySelectorAll('.kb-btn');for(var i=0;i<btns.length;i++){{btns[i].style.background='#eee';btns[i].style.color='#333'}}this.dom_element.style.background='#3b82f6';this.dom_element.style.color='#fff';var all=document.getElementById('kb_btn_all');if(all){{all.style.background='#eee';all.style.color='#333'}}"}]
|
||||
})
|
||||
|
||||
{"widgettype": "HBox", "options": {"spacing": "8px", "width": "100%", "wrap": true}, "subwidgets": buttons}
|
||||
75
wwwroot/rag/search.ui
Normal file
75
wwwroot/rag/search.ui
Normal file
@ -0,0 +1,75 @@
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"id": "rag_search_page",
|
||||
"options": {"width": "100%", "padding": "24px", "spacing": "16px"},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {"text": "知识库检索", "cfontsize": 24, "fontWeight": "bold"}
|
||||
},
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {"text": "文本搜索,自动向量化 + 重排序,支持全库或指定知识库", "cfontsize": 13, "color": "#888"}
|
||||
},
|
||||
{
|
||||
"widgettype": "Form",
|
||||
"id": "search_form",
|
||||
"options": {
|
||||
"submit_url": "/rag/search_result.dspy",
|
||||
"fields": [
|
||||
{"name": "query", "label": "搜索", "uitype": "Text", "required": false, "placeholder": "输入关键词检索知识库..."},
|
||||
{"name": "kb_id", "label": "kb_id", "uitype": "Text", "required": false, "hidden": true, "value": ""},
|
||||
{"name": "top_k", "label": "top_k", "uitype": "Text", "required": false, "hidden": true, "value": "10"},
|
||||
{"name": "recall_k", "label": "recall_k", "uitype": "Text", "required": false, "hidden": true, "value": "30"}
|
||||
]
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"options": {"text": "知识库:", "cfontsize": 13, "color": "#666", "padding": "4px 0 8px 0"}
|
||||
},
|
||||
{
|
||||
"widgettype": "HBox",
|
||||
"id": "kb_selector_row",
|
||||
"options": {"spacing": "8px", "width": "100%", "wrap": true},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Text",
|
||||
"id": "kb_btn_all",
|
||||
"options": {"text": "全部知识库", "cfontsize": 12, "padding": "4px 12px", "bgcolor": "#3b82f6", "color": "#fff", "borderRadius": "4px", "css": "clickable"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self",
|
||||
"script": "var f=document.querySelector('[name=kb_id]');if(f)f.value='';var btns=document.querySelectorAll('.kb-btn');for(var i=0;i<btns.length;i++){btns[i].style.background='#eee';btns[i].style.color='#333'}this.dom_element.style.background='#3b82f6';this.dom_element.style.color='#fff'"}]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "HBox",
|
||||
"id": "kb_list_area",
|
||||
"options": {"spacing": "8px", "width": "100%", "wrap": true},
|
||||
"binds": [{"wid": "self", "event": "rendered", "actiontype": "urlwidget", "target": "self", "mode": "replace",
|
||||
"options": {"url": "/rag/kb_buttons.dspy"}}]
|
||||
},
|
||||
{"widgettype": "Text", "options": {"text": " ", "cheight": 0.3}},
|
||||
{
|
||||
"widgettype": "HBox",
|
||||
"options": {"spacing": "12px"},
|
||||
"subwidgets": [
|
||||
{"widgettype": "Button", "id": "search_submit_btn", "options": {"label": "搜索", "bgcolor": "#3b82f6", "color": "#fff", "borderRadius": "6px", "padding": "8px 24px", "cfontsize": 14}}
|
||||
]
|
||||
}
|
||||
],
|
||||
"binds": [
|
||||
{"wid": "self", "event": "submited", "actiontype": "urlwidget", "target": "root.search_results_container", "mode": "replace",
|
||||
"options": {"url": "/rag/search_result.dspy"}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"id": "search_results_container",
|
||||
"options": {"width": "100%", "spacing": "12px"},
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"text": "输入关键词搜索知识库内容", "cfontsize": 14, "color": "#aaa", "halign": "center", "padding": "40px"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
106
wwwroot/rag/search_result.dspy
Normal file
106
wwwroot/rag/search_result.dspy
Normal file
@ -0,0 +1,106 @@
|
||||
"""搜索 DSPY — 接收 Form 参数,返回搜索结果 Widget"""
|
||||
query = (params_kw.get("query") or "").strip()
|
||||
kb_id = (params_kw.get("kb_id") or "").strip()
|
||||
top_k = int(params_kw.get("top_k") or 10)
|
||||
recall_k = int(params_kw.get("recall_k") or top_k * 3)
|
||||
env = request._run_ns
|
||||
userorgid = await env.get_userorgid()
|
||||
|
||||
# Resolve KBs
|
||||
async with get_sor_context(env, 'rag') as sor:
|
||||
if kb_id:
|
||||
recs = await sor.R("knowledge_bases", {"id": kb_id})
|
||||
kb_ids = [r.id for r in recs]
|
||||
else:
|
||||
sql = "SELECT id FROM knowledge_bases WHERE org_id IS NULL OR org_id=${org_id}$"
|
||||
recs = await sor.sqlExe(sql, {"org_id": userorgid})
|
||||
kb_ids = [r.id for r in recs]
|
||||
|
||||
if not kb_ids:
|
||||
result = {"widgettype": "Text", "options": {"text": "没有可搜索的知识库", "color": "#aaa", "halign": "center", "padding": "40px", "cfontsize": 14}}
|
||||
|
||||
elif not query:
|
||||
result = {"widgettype": "Text", "options": {"text": "请输入搜索关键词", "color": "#aaa", "halign": "center", "padding": "40px", "cfontsize": 14}}
|
||||
|
||||
else:
|
||||
# Build embedding
|
||||
from rag.init import _call_uapi
|
||||
try:
|
||||
emb_resp = await _call_uapi("rag-embedding", "embed", {"texts": [query], "model": "CLIP-ViT-H-14"})
|
||||
query_vec = (emb_resp.get("embeddings", []) or [None])[0] if isinstance(emb_resp, dict) else None
|
||||
except Exception:
|
||||
query_vec = None
|
||||
|
||||
if not query_vec:
|
||||
result = {"widgettype": "Text", "options": {"text": "向量化失败,请稍后重试", "color": "#e53e3e", "halign": "center", "padding": "40px", "cfontsize": 14}}
|
||||
else:
|
||||
# Multi-KB VDB search
|
||||
all_hits = []
|
||||
for kid in kb_ids:
|
||||
try:
|
||||
vdb_resp = await _call_uapi("rag-vdb", "search", {"collection": kid, "vector": query_vec, "topK": recall_k})
|
||||
data = vdb_resp
|
||||
if isinstance(data, dict):
|
||||
for key in ("results", "data", "hits", "rows"):
|
||||
if isinstance(data.get(key), list):
|
||||
data = data[key]
|
||||
break
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
all_hits.append({"id": item.get("id", ""), "text": item.get("text", item.get("content", "")),
|
||||
"score": item.get("score", item.get("distance", 0)), "kb_id": kid})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Deduplicate + sort
|
||||
seen = set()
|
||||
unique_hits = []
|
||||
for h in sorted(all_hits, key=lambda x: x.get("score", 0), reverse=True):
|
||||
hid = h.get("id", h.get("text", ""))
|
||||
if hid not in seen:
|
||||
seen.add(hid)
|
||||
unique_hits.append(h)
|
||||
|
||||
# Rerank
|
||||
if query and unique_hits:
|
||||
docs = [h.get("text", "")[:500] for h in unique_hits[:recall_k]]
|
||||
try:
|
||||
rr = await _call_uapi("rag-reranker", "rerank", {"query": query, "documents": docs})
|
||||
scores = rr.get("scores", rr.get("results", [])) if isinstance(rr, dict) else []
|
||||
if isinstance(scores, list) and len(scores) == len(docs):
|
||||
for i, s in enumerate(scores):
|
||||
unique_hits[i]["rerank_score"] = s.get("score", 0) if isinstance(s, dict) else float(s or 0)
|
||||
unique_hits.sort(key=lambda x: x.get("rerank_score", 0), reverse=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
final = unique_hits[:top_k]
|
||||
|
||||
if not final:
|
||||
result = {"widgettype": "Text", "options": {"text": "未找到相关结果", "color": "#aaa", "halign": "center", "padding": "40px", "cfontsize": 14}}
|
||||
else:
|
||||
cards = []
|
||||
for i, h in enumerate(final):
|
||||
text = h.get("text", "")[:300]
|
||||
score = h.get("rerank_score", h.get("score", 0))
|
||||
score_pct = f"{min(abs(float(score)) * 100, 100):.0f}%" if score else ""
|
||||
cards.append({
|
||||
"widgettype": "VBox",
|
||||
"options": {"cwidth": 100, "padding": "16px", "bgcolor": "#f8fafc", "border": "1px solid #e2e8f0", "borderRadius": "8px", "spacing": "8px"},
|
||||
"subwidgets": [
|
||||
{"widgettype": "HBox", "options": {"spacing": "8px"}, "subwidgets": [
|
||||
{"widgettype": "Text", "options": {"text": f"#{i+1}", "cfontsize": 12, "color": "#3b82f6", "fontWeight": "bold", "padding": "2px 8px", "bgcolor": "#eff6ff", "borderRadius": "4px"}},
|
||||
{"widgettype": "Text", "options": {"text": f"相关度: {score_pct}", "cfontsize": 11, "color": "#888"}},
|
||||
{"widgettype": "Text", "options": {"text": f"KB: {h.get('kb_id','')[:12]}", "cfontsize": 11, "color": "#aaa"}}
|
||||
]},
|
||||
{"widgettype": "Text", "options": {"text": text, "cfontsize": 13, "color": "#333", "lineHeight": 1.6}},
|
||||
{"widgettype": "Text", "options": {"text": h.get("id", "")[:40], "cfontsize": 10, "color": "#bbb"}}
|
||||
]
|
||||
})
|
||||
result = {"widgettype": "VBox", "options": {"width": "100%", "spacing": "12px"}, "subwidgets": [
|
||||
{"widgettype": "Text", "options": {"text": f"找到 {len(final)} 条结果 (共召回 {len(all_hits)} 条)", "cfontsize": 13, "color": "#666", "padding": "0 0 8px 0"}},
|
||||
*cards
|
||||
]}
|
||||
|
||||
result
|
||||
Loading…
x
Reference in New Issue
Block a user