feat: 标签支持多选后直接检索,无关键词时可纯标签检索

- search_result.dspy: 标签解析提前,query为空但标签有值时走直接DB查询
- search.ui: tag_selector添加changed事件触发检索,更新首页提示文案
This commit is contained in:
ymq 2026-08-10 11:20:00 +08:00
parent 70a958180f
commit 3425c09188
2 changed files with 130 additions and 88 deletions

View File

@ -3,7 +3,7 @@
"options": {"padding": "20px", "spacing": "12px", "width": "100%", "height": "100%", "css": "filler"}, "options": {"padding": "20px", "spacing": "12px", "width": "100%", "height": "100%", "css": "filler"},
"subwidgets": [ "subwidgets": [
{"widgettype": "Text", "options": {"text": "🔍 知识检索", "cfontsize": 22, "fontWeight": "bold", "color": "#1a1a2e"}}, {"widgettype": "Text", "options": {"text": "🔍 知识检索", "cfontsize": 22, "fontWeight": "bold", "color": "#1a1a2e"}},
{"widgettype": "Text", "options": {"text": "选择知识库,输入文字或上传文件检索", "cfontsize": 13, "color": "#888"}}, {"widgettype": "Text", "options": {"text": "选择知识库,输入文字、选择标签或上传文件检索", "cfontsize": 13, "color": "#888"}},
{ {
"widgettype": "HBox", "widgettype": "HBox",
"options": {"spacing": "12px", "alignItems": "center"}, "options": {"spacing": "12px", "alignItems": "center"},
@ -64,6 +64,22 @@
} }
} }
}, },
{
"wid": "tag_selector",
"event": "changed",
"actiontype": "urlwidget",
"target": "search_results",
"mode": "replace",
"options": {
"url": "/rag/knowledge_bases_list/search_result.dspy",
"params": {
"_webbricks_": 1,
"kb_id": "{{kb_selector}}",
"keyword": "{{search_bar}}",
"tag_ids": "{{tag_selector}}"
}
}
},
{ {
"wid": "search_file", "wid": "search_file",
"event": "changed", "event": "changed",

View File

@ -34,16 +34,7 @@ if not file_data:
import base64, io import base64, io
top_k = int(ns.get('top_k', 5)) top_k = int(ns.get('top_k', 5))
if not query: # 0. Parse tag filter FIRST — find doc_ids that match ALL selected tags
return json.dumps({
"widgettype": "VBox", "options": {"padding": "20px", "spacing": "16px"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": "🔍 知识检索", "cfontsize": 20, "fontWeight": "bold"}},
{"widgettype": "Text", "options": {"text": "请输入检索内容", "color": "#666", "marginTop": "20px"}}
]
}, ensure_ascii=False)
# 0. Parse tag filter — find doc_ids that match ALL selected tags
tag_doc_ids = None tag_doc_ids = None
tag_info = "" tag_info = ""
if tag_ids_str: if tag_ids_str:
@ -65,11 +56,26 @@ if tag_ids_str:
"GROUP BY media_id HAVING COUNT(DISTINCT tag_id)=" + str(len(wanted_tags))) "GROUP BY media_id HAVING COUNT(DISTINCT tag_id)=" + str(len(wanted_tags)))
recs = await sor.sqlExe(sql, nsq) recs = await sor.sqlExe(sql, nsq)
tag_doc_ids = set(r.media_id for r in recs) tag_doc_ids = set(r.media_id for r in recs)
tag_info = " (标签过滤: " + ",".join(wanted_tags[:3]) + ("..." if len(wanted_tags)>3 else "") + ")" 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') info(f'[search_result] tag filter: {len(tag_doc_ids)} docs match {len(wanted_tags)} tags')
except Exception as e: except Exception as e:
info(f'[search_result] tag filter error: {e}') info(f'[search_result] tag filter error: {e}')
# Early return only when nothing is provided
if not query and not tag_ids_str and not file_data:
return json.dumps({
"widgettype": "VBox", "options": {"padding": "20px", "spacing": "16px"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": "🔍 知识检索", "cfontsize": 20, "fontWeight": "bold"}},
{"widgettype": "Text", "options": {"text": "请输入检索内容或选择标签", "color": "#666", "marginTop": "20px"}}
]
}, ensure_ascii=False)
hits = []
raw_rows = []
kw_rows = []
if query:
# 1. Embed # 1. Embed
vec = [] vec = []
try: try:
@ -84,7 +90,6 @@ if not vec:
return json.dumps({"widgettype": "Text", "options": {"text": "向量化失败", "cfontsize": 14, "color": "#e74c3c"}}, ensure_ascii=False) return json.dumps({"widgettype": "Text", "options": {"text": "向量化失败", "cfontsize": 14, "color": "#e74c3c"}}, ensure_ascii=False)
# 2. VDB search # 2. VDB search
raw_rows = []
try: try:
recall_n = top_k * 3 recall_n = top_k * 3
client2 = StreamHttpClient() client2 = StreamHttpClient()
@ -99,7 +104,6 @@ except:
# 3. Keyword recall (hybrid search: vector alone misses technical terms) # 3. Keyword recall (hybrid search: vector alone misses technical terms)
kw_ids = set() kw_ids = set()
kw_rows = []
try: try:
tokens = [t for t in query.split() if t][:5] or [query] tokens = [t for t in query.split() if t][:5] or [query]
conds = [] conds = []
@ -116,7 +120,6 @@ try:
except Exception as e: except Exception as e:
info('[search_result] keyword recall failed: %s' % e) info('[search_result] keyword recall failed: %s' % e)
hits = []
seen = set() seen = set()
for row in raw_rows: for row in raw_rows:
rid = str(row.get("id", "")) rid = str(row.get("id", ""))
@ -149,9 +152,32 @@ for kr in kw_rows:
hits.sort(key=lambda x: x.get("score", 0), reverse=True) hits.sort(key=lambda x: x.get("score", 0), reverse=True)
hits = hits[:top_k] hits = hits[:top_k]
else:
# Tag-only search: direct DB lookup for chunks of tagged documents
if tag_doc_ids:
try:
async with get_sor_context(env, 'rag') as sor:
kb_cond = ""
nsq2 = {}
if kb_id and kb_id != "all":
kb_cond = "kb_id=${kb_id}$ AND "
nsq2["kb_id"] = kb_id
placeholders2 = []
for i, did in enumerate(tag_doc_ids):
placeholders2.append("${did_" + str(i) + "}$")
nsq2["did_" + str(i)] = did
sql2 = ("SELECT id, doc_id, content FROM document_chunks WHERE " + kb_cond +
"doc_id IN (" + ",".join(placeholders2) + ") ORDER BY updated_at DESC LIMIT " + str(top_k))
crecs = await sor.sqlExe(sql2, nsq2)
for r in crecs:
hits.append({"id": r.id, "doc_id": r.doc_id or "", "score": 1.0, "text": r.content or '', "kw": False})
except Exception as e:
info(f'[search_result] tag-only lookup error: {e}')
header_text = f"🔍 检索: {query}" if query else "🔍 标签检索"
subwidgets = [ subwidgets = [
{"widgettype": "Text", "options": {"text": f"🔍 检索: {query}{tag_info}", "cfontsize": 18, "fontWeight": "bold", "marginBottom": "8px"}}, {"widgettype": "Text", "options": {"text": header_text + tag_info, "cfontsize": 18, "fontWeight": "bold", "marginBottom": "8px"}},
{"widgettype": "Text", "options": {"text": f"共 {len(hits)} 条结果 (召回 {len(raw_rows)} 条)", "cfontsize": 13, "color": "#888", "marginBottom": "16px"}} {"widgettype": "Text", "options": {"text": f"共 {len(hits)} 条结果" + (f" (召回 {len(raw_rows)} 条)" if raw_rows else ""), "cfontsize": 13, "color": "#888", "marginBottom": "16px"}}
] ]
if not hits: if not hits: