feat: RAG search frontend — search.ui, search_result.dspy, kb_buttons.dspy

This commit is contained in:
yumoqing 2026-07-27 14:12:08 +08:00
parent 2e1c01a036
commit 6f3858a8f2
3 changed files with 199 additions and 0 deletions

View 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
View 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"}}
]
}
]
}

View 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