feat: 标签支持多选后直接检索,无关键词时可纯标签检索
- search_result.dspy: 标签解析提前,query为空但标签有值时走直接DB查询 - search.ui: tag_selector添加changed事件触发检索,更新首页提示文案
This commit is contained in:
parent
70a958180f
commit
3425c09188
@ -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",
|
||||||
|
|||||||
@ -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,93 +56,128 @@ 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}')
|
||||||
|
|
||||||
# 1. Embed
|
# Early return only when nothing is provided
|
||||||
vec = []
|
if not query and not tag_ids_str and not file_data:
|
||||||
try:
|
return json.dumps({
|
||||||
client = StreamHttpClient()
|
"widgettype": "VBox", "options": {"padding": "20px", "spacing": "16px"},
|
||||||
resp = await client.request('POST', 'https://embedding.opencomputing.net:10443/api/embed',
|
"subwidgets": [
|
||||||
json={"texts": [query], "model": "CLIP-ViT-H-14"})
|
{"widgettype": "Text", "options": {"text": "🔍 知识检索", "cfontsize": 20, "fontWeight": "bold"}},
|
||||||
emb = json.loads(resp)
|
{"widgettype": "Text", "options": {"text": "请输入检索内容或选择标签", "color": "#666", "marginTop": "20px"}}
|
||||||
vec = emb.get("text_embeddings", emb.get("embeddings", [[]]))[0]
|
]
|
||||||
except:
|
}, ensure_ascii=False)
|
||||||
pass
|
|
||||||
if not vec:
|
|
||||||
return json.dumps({"widgettype": "Text", "options": {"text": "向量化失败", "cfontsize": 14, "color": "#e74c3c"}}, ensure_ascii=False)
|
|
||||||
|
|
||||||
# 2. VDB search
|
|
||||||
raw_rows = []
|
|
||||||
try:
|
|
||||||
recall_n = top_k * 3
|
|
||||||
client2 = StreamHttpClient()
|
|
||||||
resp2 = await client2.request('POST', 'https://vectordb.opencomputing.net:10443/v1/query',
|
|
||||||
json={"colname": kb_id, "vector": vec, "pagerows": recall_n, "output_fields": ["*"]})
|
|
||||||
vdb = json.loads(resp2)
|
|
||||||
raw_rows = vdb.get("data", {}).get("rows", [])
|
|
||||||
if not isinstance(raw_rows, list):
|
|
||||||
raw_rows = []
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 3. Keyword recall (hybrid search: vector alone misses technical terms)
|
|
||||||
kw_ids = set()
|
|
||||||
kw_rows = []
|
|
||||||
try:
|
|
||||||
tokens = [t for t in query.split() if t][:5] or [query]
|
|
||||||
conds = []
|
|
||||||
nsq = {"kb_id": kb_id}
|
|
||||||
for i, t in enumerate(tokens):
|
|
||||||
conds.append("content LIKE ${kw_" + str(i) + "}$")
|
|
||||||
nsq["kw_" + str(i)] = "%" + t + "%"
|
|
||||||
ksql = "SELECT id, doc_id, content FROM document_chunks WHERE kb_id=${kb_id}$ AND (" + " OR ".join(conds) + ") LIMIT 20"
|
|
||||||
async with get_sor_context(env, 'rag') as sor:
|
|
||||||
krecs = await sor.sqlExe(ksql, nsq)
|
|
||||||
for r in krecs:
|
|
||||||
kw_ids.add(r.id)
|
|
||||||
kw_rows.append({"id": r.id, "doc_id": r.doc_id or "", "score": 0.99, "text": r.content or '', "kw": True})
|
|
||||||
except Exception as e:
|
|
||||||
info('[search_result] keyword recall failed: %s' % e)
|
|
||||||
|
|
||||||
hits = []
|
hits = []
|
||||||
seen = set()
|
raw_rows = []
|
||||||
for row in raw_rows:
|
kw_rows = []
|
||||||
rid = str(row.get("id", ""))
|
|
||||||
score = row.get("score", 0)
|
if query:
|
||||||
# Resolve doc_id from chunk id pattern "xxxx_c0"
|
# 1. Embed
|
||||||
doc_id = rid.rsplit('_c', 1)[0] if '_c' in rid else rid
|
vec = []
|
||||||
# Look up chunk text from DB
|
try:
|
||||||
chunk_text = ''
|
client = StreamHttpClient()
|
||||||
async with get_sor_context(env, 'rag') as sor:
|
resp = await client.request('POST', 'https://embedding.opencomputing.net:10443/api/embed',
|
||||||
recs = await sor.sqlExe(
|
json={"texts": [query], "model": "CLIP-ViT-H-14"})
|
||||||
"SELECT content, doc_id FROM document_chunks WHERE id=${id}$",
|
emb = json.loads(resp)
|
||||||
{"id": rid})
|
vec = emb.get("text_embeddings", emb.get("embeddings", [[]]))[0]
|
||||||
if recs:
|
except:
|
||||||
chunk_text = recs[0].content or ''
|
pass
|
||||||
doc_id = recs[0].doc_id or doc_id
|
if not vec:
|
||||||
# Tag filter
|
return json.dumps({"widgettype": "Text", "options": {"text": "向量化失败", "cfontsize": 14, "color": "#e74c3c"}}, ensure_ascii=False)
|
||||||
if tag_doc_ids is not None and doc_id not in tag_doc_ids:
|
|
||||||
continue
|
# 2. VDB search
|
||||||
if chunk_text:
|
try:
|
||||||
is_kw = rid in kw_ids
|
recall_n = top_k * 3
|
||||||
hits.append({"id": rid, "doc_id": doc_id, "score": 0.99 if is_kw else score, "text": chunk_text, "kw": is_kw})
|
client2 = StreamHttpClient()
|
||||||
seen.add(rid)
|
resp2 = await client2.request('POST', 'https://vectordb.opencomputing.net:10443/v1/query',
|
||||||
for kr in kw_rows:
|
json={"colname": kb_id, "vector": vec, "pagerows": recall_n, "output_fields": ["*"]})
|
||||||
if kr["id"] not in seen:
|
vdb = json.loads(resp2)
|
||||||
if tag_doc_ids is not None and kr["doc_id"] not in tag_doc_ids:
|
raw_rows = vdb.get("data", {}).get("rows", [])
|
||||||
|
if not isinstance(raw_rows, list):
|
||||||
|
raw_rows = []
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. Keyword recall (hybrid search: vector alone misses technical terms)
|
||||||
|
kw_ids = set()
|
||||||
|
try:
|
||||||
|
tokens = [t for t in query.split() if t][:5] or [query]
|
||||||
|
conds = []
|
||||||
|
nsq = {"kb_id": kb_id}
|
||||||
|
for i, t in enumerate(tokens):
|
||||||
|
conds.append("content LIKE ${kw_" + str(i) + "}$")
|
||||||
|
nsq["kw_" + str(i)] = "%" + t + "%"
|
||||||
|
ksql = "SELECT id, doc_id, content FROM document_chunks WHERE kb_id=${kb_id}$ AND (" + " OR ".join(conds) + ") LIMIT 20"
|
||||||
|
async with get_sor_context(env, 'rag') as sor:
|
||||||
|
krecs = await sor.sqlExe(ksql, nsq)
|
||||||
|
for r in krecs:
|
||||||
|
kw_ids.add(r.id)
|
||||||
|
kw_rows.append({"id": r.id, "doc_id": r.doc_id or "", "score": 0.99, "text": r.content or '', "kw": True})
|
||||||
|
except Exception as e:
|
||||||
|
info('[search_result] keyword recall failed: %s' % e)
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for row in raw_rows:
|
||||||
|
rid = str(row.get("id", ""))
|
||||||
|
score = row.get("score", 0)
|
||||||
|
# Resolve doc_id from chunk id pattern "xxxx_c0"
|
||||||
|
doc_id = rid.rsplit('_c', 1)[0] if '_c' in rid else rid
|
||||||
|
# Look up chunk text from DB
|
||||||
|
chunk_text = ''
|
||||||
|
async with get_sor_context(env, 'rag') as sor:
|
||||||
|
recs = await sor.sqlExe(
|
||||||
|
"SELECT content, doc_id FROM document_chunks WHERE id=${id}$",
|
||||||
|
{"id": rid})
|
||||||
|
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
|
continue
|
||||||
hits.append(kr)
|
if chunk_text:
|
||||||
seen.add(kr["id"])
|
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"])
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user