feat: 知识检索添加多选标签过滤 — tag_options.dspy + search.ui标签芯片 + search_result按标签过滤
This commit is contained in:
parent
c912805534
commit
1206e27336
@ -1,6 +1,6 @@
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"padding": "20px", "spacing": "16px", "width": "100%", "height": "100%", "css": "filler"},
|
||||
"options": {"padding": "20px", "spacing": "12px", "width": "100%", "height": "100%", "css": "filler"},
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"text": "🔍 知识检索", "cfontsize": 22, "fontWeight": "bold", "color": "#1a1a2e"}},
|
||||
{"widgettype": "Text", "options": {"text": "选择知识库,输入文字或上传文件检索", "cfontsize": 13, "color": "#888"}},
|
||||
@ -26,9 +26,24 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "HBox",
|
||||
"options": {"spacing": "8px", "alignItems": "center", "wrap": true},
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"text": "🏷 标签过滤:", "cfontsize": 12, "color": "#666", "padding": "2px 0"}},
|
||||
{
|
||||
"widgettype": "HBox",
|
||||
"id": "tag_filter",
|
||||
"options": {"spacing": "4px", "alignItems": "center", "wrap": true, "padding": "2px 0"},
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"text": "选择知识库后加载标签", "cfontsize": 11, "color": "#ccc"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"css": "filler", "marginTop": "16px", "width": "100%", "padding": "0", "spacing": "0"},
|
||||
"options": {"css": "filler", "marginTop": "4px", "width": "100%", "padding": "0", "spacing": "0"},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "VScrollPanel",
|
||||
@ -42,18 +57,24 @@
|
||||
}
|
||||
],
|
||||
"binds": [
|
||||
{
|
||||
"wid": "kb_selector",
|
||||
"event": "change",
|
||||
"actiontype": "script",
|
||||
"script": "var kb_id=this.resultValue()||'all';var tf=document.querySelector('#tag_filter');if(!tf)return;if(kb_id==='all'){tf.innerHTML='<span style=font-size:11px;color:#ccc>选择具体知识库后加载标签</span>';return};fetch('/rag/knowledge_bases_list/tag_options.dspy?_webbricks_=1&kb_id='+encodeURIComponent(kb_id)).then(function(r){return r.json()}).then(function(tags){tf.innerHTML='';if(!tags||tags.length===0){tf.innerHTML='<span style=font-size:11px;color:#ccc>该知识库暂无标签</span>';return};tags.forEach(function(t){var chip=document.createElement('span');chip.setAttribute('data-tid',t.value);chip.setAttribute('data-sel','0');chip.style.cssText='display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;cursor:pointer;margin:2px 4px;border:1px solid #ddd;background:#f5f5f5;color:#666;transition:all .2s';chip.textContent='#'+t.text;chip.onclick=function(){var el=this;var on=el.getAttribute('data-sel')==='1';el.setAttribute('data-sel',on?'0':'1');el.style.background=on?'#f5f5f5':'#e3f2fd';el.style.color=on?'#666':'#1565c0';el.style.borderColor=on?'#ddd':'#90caf9';};tf.appendChild(chip)})}).catch(function(){tf.innerHTML='<span style=font-size:11px;color:#e74c3c>加载标签失败</span>'})"
|
||||
},
|
||||
{
|
||||
"wid": "search_bar",
|
||||
"event": "search",
|
||||
"actiontype": "script",
|
||||
"script": "var kw=event.params.keyword||'';var kb=bricks.getWidgetById('kb_selector',this);var kb_id=(kb&&kb.resultValue())||'all';var target=bricks.getWidgetById('search_results',this);if(target){bricks.buildUrlwidgetHandler(this,target,{},{options:{url:'/rag/knowledge_bases_list/search_result.dspy',params:{_webbricks_:1,kb_id:kb_id,keyword:kw}},mode:'replace'})()}"
|
||||
"script": "var kw=event.params.keyword||'';var kb=bricks.getWidgetById('kb_selector',this);var kb_id=(kb&&kb.resultValue())||'all';var tag_ids='';var chips=document.querySelectorAll('#tag_filter [data-tid]');chips.forEach(function(c){if(c.getAttribute('data-sel')==='1'){tag_ids+=(tag_ids?',':'')+c.getAttribute('data-tid')}});var target=bricks.getWidgetById('search_results',this);if(target){bricks.buildUrlwidgetHandler(this,target,{},{options:{url:'/rag/knowledge_bases_list/search_result.dspy',params:{_webbricks_:1,kb_id:kb_id,keyword:kw,tag_ids:tag_ids}},mode:'replace'})()}"
|
||||
},
|
||||
{
|
||||
"wid": "search_file",
|
||||
"event": "changed",
|
||||
"actiontype": "script",
|
||||
"target": "search_file",
|
||||
"script": "var f=this.value;if(!f)return;if(Array.isArray(f))f=f[0];if(!f)return;var kb=bricks.getWidgetById('kb_selector',this.parent);var kb_id=(kb&&kb.resultValue())||'all';var fd=new FormData();fd.append('file',f);var u='/rag/knowledge_bases_list/search_result.dspy?_webbricks_=1&kb_id='+encodeURIComponent(kb_id)+'&file_name='+encodeURIComponent(f.name);var pv=this.parent.parent;fetch(u,{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){var fp=bricks.getWidgetById('search_results',pv);if(fp){fp.clear_widgets();bricks.widgetBuild(d,fp).then(function(w){if(w)fp.add_widget(w)})}})"
|
||||
"script": "var f=this.value;if(!f)return;if(Array.isArray(f))f=f[0];if(!f)return;var kb=bricks.getWidgetById('kb_selector',this.parent);var kb_id=(kb&&kb.resultValue())||'all';var tag_ids='';var chips=document.querySelectorAll('#tag_filter [data-tid]');chips.forEach(function(c){if(c.getAttribute('data-sel')==='1'){tag_ids+=(tag_ids?',':'')+c.getAttribute('data-tid')}});var fd=new FormData();fd.append('file',f);var u='/rag/knowledge_bases_list/search_result.dspy?_webbricks_=1&kb_id='+encodeURIComponent(kb_id)+'&file_name='+encodeURIComponent(f.name)+'&tag_ids='+encodeURIComponent(tag_ids);var pv=this.parent.parent;fetch(u,{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){var fp=bricks.getWidgetById('search_results',pv);if(fp){fp.clear_widgets();bricks.widgetBuild(d,fp).then(function(w){if(w)fp.add_widget(w)})}})"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ ns = params_kw.copy()
|
||||
kb_id = ns.get('kb_id', '')
|
||||
env = request._run_ns
|
||||
query = ns.get("query") or ns.get("value") or ns.get("keyword") or ns.get("text") or ""
|
||||
tag_ids_str = ns.get("tag_ids", "")
|
||||
# Defensive: legacy UI cached in browser double-encodes the keyword (%E5%88%86... arrives as-is).
|
||||
# Normal path: server already decoded once, no '%' present, this is a no-op.
|
||||
if "%" in query:
|
||||
@ -14,7 +15,7 @@ if "%" in query:
|
||||
except Exception:
|
||||
pass
|
||||
info(f'[search_result] params_kw={params_kw}')
|
||||
info(f'[search_result] parsed: kb_id={kb_id!r} query={query!r} method={request.method} url={request.url}')
|
||||
info(f'[search_result] parsed: kb_id={kb_id!r} query={query!r} tag_ids={tag_ids_str!r} method={request.method} url={request.url}')
|
||||
|
||||
file_data = None
|
||||
file_name = None
|
||||
@ -42,6 +43,29 @@ if not query:
|
||||
]
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 0. Parse tag filter — find doc_ids that match ALL selected tags
|
||||
tag_doc_ids = None
|
||||
tag_info = ""
|
||||
if tag_ids_str:
|
||||
wanted_tags = [t.strip() for t in tag_ids_str.split(",") if t.strip()]
|
||||
if wanted_tags:
|
||||
try:
|
||||
async with get_sor_context(env, 'rag') as sor:
|
||||
placeholders = []
|
||||
nsq = {"kb_id": kb_id}
|
||||
for i, tid in enumerate(wanted_tags):
|
||||
placeholders.append("${tid_" + str(i) + "}$")
|
||||
nsq["tid_" + str(i)] = tid
|
||||
sql = ("SELECT media_id FROM media_tags "
|
||||
"WHERE kb_id=${kb_id}$ AND media_type='document' AND tag_id IN (" + ",".join(placeholders) + ") "
|
||||
"GROUP BY media_id HAVING COUNT(DISTINCT tag_id)=" + str(len(wanted_tags)))
|
||||
recs = await sor.sqlExe(sql, nsq)
|
||||
tag_doc_ids = set(r.media_id for r in recs)
|
||||
tag_info = " (标签过滤: " + ",".join(wanted_tags[:3]) + ("..." if len(wanted_tags)>3 else "") + ")"
|
||||
info(f'[search_result] tag filter: {len(tag_doc_ids)} docs match {len(wanted_tags)} tags')
|
||||
except Exception as e:
|
||||
info(f'[search_result] tag filter error: {e}')
|
||||
|
||||
# 1. Embed
|
||||
vec = []
|
||||
try:
|
||||
@ -97,7 +121,6 @@ for row in raw_rows:
|
||||
doc_id = rid.rsplit('_c', 1)[0] if '_c' in rid else rid
|
||||
# Look up chunk text from DB
|
||||
chunk_text = ''
|
||||
doc_name = ''
|
||||
async with get_sor_context(env, 'rag') as sor:
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT content, doc_id FROM document_chunks WHERE id=${id}$",
|
||||
@ -105,12 +128,17 @@ for row in raw_rows:
|
||||
if recs:
|
||||
chunk_text = recs[0].content or ''
|
||||
doc_id = recs[0].doc_id or doc_id
|
||||
# Tag filter
|
||||
if tag_doc_ids is not None and doc_id not in tag_doc_ids:
|
||||
continue
|
||||
if chunk_text:
|
||||
is_kw = rid in kw_ids
|
||||
hits.append({"id": rid, "doc_id": doc_id, "score": 0.99 if is_kw else score, "text": chunk_text, "kw": is_kw})
|
||||
seen.add(rid)
|
||||
for kr in kw_rows:
|
||||
if kr["id"] not in seen:
|
||||
if tag_doc_ids is not None and kr["doc_id"] not in tag_doc_ids:
|
||||
continue
|
||||
hits.append(kr)
|
||||
seen.add(kr["id"])
|
||||
|
||||
@ -118,7 +146,7 @@ hits.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||||
hits = hits[:top_k]
|
||||
|
||||
subwidgets = [
|
||||
{"widgettype": "Text", "options": {"text": f"🔍 检索: {query}", "cfontsize": 18, "fontWeight": "bold", "marginBottom": "8px"}},
|
||||
{"widgettype": "Text", "options": {"text": f"🔍 检索: {query}{tag_info}", "cfontsize": 18, "fontWeight": "bold", "marginBottom": "8px"}},
|
||||
{"widgettype": "Text", "options": {"text": f"共 {len(hits)} 条结果 (召回 {len(raw_rows)} 条)", "cfontsize": 13, "color": "#888", "marginBottom": "16px"}}
|
||||
]
|
||||
|
||||
|
||||
12
wwwroot/knowledge_bases_list/tag_options.dspy
Normal file
12
wwwroot/knowledge_bases_list/tag_options.dspy
Normal file
@ -0,0 +1,12 @@
|
||||
import json
|
||||
ns = params_kw.copy()
|
||||
kb_id = ns.get('kb_id', '')
|
||||
if not kb_id or kb_id == 'all':
|
||||
return json.dumps([], ensure_ascii=False)
|
||||
env = request._run_ns
|
||||
async with get_sor_context(env, 'rag') as sor:
|
||||
recs = await sor.sqlExe(
|
||||
"SELECT id, name, color FROM tags WHERE kb_id=${kb_id}$ ORDER BY created_at",
|
||||
{"kb_id": kb_id})
|
||||
items = [{"value": r.id, "text": r.name, "color": r.color or '#3b82f6'} for r in recs]
|
||||
return json.dumps(items, ensure_ascii=False)
|
||||
Loading…
x
Reference in New Issue
Block a user