feat: 知识检索添加多选标签过滤 — tag_options.dspy + search.ui标签芯片 + search_result按标签过滤

This commit is contained in:
ymq 2026-08-07 17:27:47 +08:00
parent c912805534
commit 1206e27336
3 changed files with 68 additions and 7 deletions

View File

@ -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)})}})"
}
]
}

View File

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

View 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)