365 lines
17 KiB
Plaintext
365 lines
17 KiB
Plaintext
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:
|
||
try:
|
||
from urllib.parse import unquote
|
||
d = unquote(query)
|
||
if d != query:
|
||
info(f'[search_result] unquote fallback: {query!r} -> {d!r}')
|
||
query = d
|
||
except Exception:
|
||
pass
|
||
info(f'[search_result] params_kw={params_kw}')
|
||
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
|
||
try:
|
||
reader = await request.multipart()
|
||
while True:
|
||
part = await reader.next()
|
||
if part is None: break
|
||
if part.name == "file": file_data = await part.read(); file_name = part.filename; break
|
||
except: pass
|
||
if not file_data:
|
||
try: file_data = await request.read();
|
||
except: pass
|
||
if file_data and len(file_data) < 10: file_data = None
|
||
|
||
import base64, io
|
||
top_k = int(ns.get('top_k', 5))
|
||
|
||
# 0. Parse tag filter FIRST — 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_filter = ""
|
||
if kb_id and kb_id != "all":
|
||
kb_filter = "kb_id=${kb_id}$ AND "
|
||
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 rag_media_tags "
|
||
"WHERE " + kb_filter + "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}')
|
||
|
||
# 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 = []
|
||
|
||
# 读知识库向量引擎:bge-m3(在线文本) / qwen3-vl-embedding(在线多模态) / clip-vith14(GPU本地)
|
||
emb_engine = 'clip-vith14'
|
||
try:
|
||
async with get_sor_context(env, 'rag') as sor:
|
||
krecs = await sor.sqlExe("SELECT embedding_engine FROM rag_knowledge_bases WHERE id=${kb_id}$", {"kb_id": kb_id})
|
||
if krecs:
|
||
emb_engine = (getattr(krecs[0], 'embedding_engine', '') or 'clip-vith14').strip()
|
||
except: pass
|
||
is_vl = emb_engine == 'qwen3-vl-embedding'
|
||
if emb_engine == 'bge-m3':
|
||
emb_url = 'https://embedding.opencomputing.net:10443/txte/api/embed'
|
||
emb_model = 'bge-m3'
|
||
else:
|
||
emb_url = 'https://embedding.opencomputing.net:10443/mme/api/embed'
|
||
emb_model = 'CLIP-ViT-H-14'
|
||
|
||
# VDB 服务地址:读 upapp.rag-vdb(生产已切内网),不硬编码
|
||
try:
|
||
async with get_sor_context(env, 'rag') as sor:
|
||
_u = await sor.sqlExe("SELECT baseurl FROM upapp WHERE id='rag-vdb'", {})
|
||
VDB_BASE = (_u[0].baseurl or '').rstrip('/') if _u else 'https://vectordb.opencomputing.net:10443'
|
||
except:
|
||
VDB_BASE = 'https://vectordb.opencomputing.net:10443'
|
||
|
||
if query:
|
||
# 1. Embed(在线:vl原生 / bge-m3兼容;GPU:CLIP /mme)
|
||
vec = []
|
||
if is_vl:
|
||
try:
|
||
from rag.vl_online import vl_embed_contents
|
||
vec = await vl_embed_contents([{"text": query}]) or []
|
||
except:
|
||
vec = []
|
||
elif emb_engine == 'bge-m3':
|
||
try:
|
||
from rag.init import _online_embed
|
||
_vs = await _online_embed(env, [query])
|
||
vec = _vs[0] if _vs else []
|
||
except:
|
||
vec = []
|
||
else:
|
||
try:
|
||
client = StreamHttpClient()
|
||
resp = await client.request('POST', emb_url,
|
||
json={"texts": [query], "model": emb_model})
|
||
emb = json.loads(resp)
|
||
vec = emb.get("text_embeddings", emb.get("embeddings", [[]]))[0]
|
||
except:
|
||
pass
|
||
if not vec:
|
||
return json.dumps({"widgettype": "Text", "options": {"text": "向量化失败(引擎未配置或不可用,请到引擎配置页检查)", "cfontsize": 14, "color": "#e74c3c"}}, ensure_ascii=False)
|
||
|
||
# 2. VDB search
|
||
try:
|
||
recall_n = top_k * 3
|
||
client2 = StreamHttpClient()
|
||
resp2 = await client2.request('POST', VDB_BASE + '/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()
|
||
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 rag_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, metadata FROM rag_document_chunks WHERE id=${id}$",
|
||
{"id": rid})
|
||
chunk_meta = {}
|
||
if recs:
|
||
chunk_text = recs[0].content or ''
|
||
doc_id = recs[0].doc_id or doc_id
|
||
try:
|
||
chunk_meta = json.loads(recs[0].metadata) if recs[0].metadata else {}
|
||
except:
|
||
pass
|
||
# 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
|
||
hit = {"id": rid, "doc_id": doc_id, "score": 0.99 if is_kw else score, "text": chunk_text, "kw": is_kw}
|
||
if chunk_meta:
|
||
hit.update({k: chunk_meta[k] for k in ('bbox', 'start_time', 'end_time') if k in chunk_meta})
|
||
hits.append(hit)
|
||
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"])
|
||
|
||
# 在线重排(mm_rerank;未配置/失败则保持召回分排序)
|
||
rr_applied = False
|
||
try:
|
||
from rag.vl_online import vl_rerank
|
||
cand = hits[:top_k * 3]
|
||
if cand:
|
||
rr = await vl_rerank(query, [h.get("text", "") for h in cand])
|
||
if rr and isinstance(rr.get("scores"), list) and len(rr["scores"]) == len(cand):
|
||
for i, s in enumerate(rr["scores"]):
|
||
cand[i]["score"] = s
|
||
cand.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||
rr_applied = True
|
||
except Exception as e:
|
||
info('[search_result] rerank failed: %s' % e)
|
||
|
||
if not rr_applied:
|
||
hits.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||
hits = hits[:top_k]
|
||
|
||
else:
|
||
# Tag-only search: query documents table directly (handles text + non-text docs)
|
||
if tag_doc_ids:
|
||
try:
|
||
async with get_sor_context(env, 'rag') as sor:
|
||
placeholders2 = []
|
||
nsq2 = {}
|
||
kb_cond = ""
|
||
if kb_id and kb_id != "all":
|
||
kb_cond = "kb_id=${kb_id}$ AND "
|
||
nsq2["kb_id"] = kb_id
|
||
for i, did in enumerate(tag_doc_ids):
|
||
placeholders2.append("${did_" + str(i) + "}$")
|
||
nsq2["did_" + str(i)] = did
|
||
# Get documents (exclude face/voice generated derivatives)
|
||
sql2 = ("SELECT id, file_name, kb_id, file_path FROM rag_documents WHERE " + kb_cond +
|
||
"id IN (" + ",".join(placeholders2) + ") AND (metadata IS NULL OR metadata NOT LIKE '%%\"face\"%%') ORDER BY created_at DESC LIMIT " + str(top_k * 2))
|
||
docs = await sor.sqlExe(sql2, nsq2)
|
||
doc_list = [(r.id, r.file_name or '', r.kb_id or '', r.file_path or '') for r in docs]
|
||
|
||
if doc_list:
|
||
doc_ids = [d[0] for d in doc_list]
|
||
# Try to get chunks for these docs
|
||
chunk_placeholders = []
|
||
nsq_c = {}
|
||
for i, did in enumerate(doc_ids):
|
||
chunk_placeholders.append("${cdid_" + str(i) + "}$")
|
||
nsq_c["cdid_" + str(i)] = did
|
||
chunks_sql = ("SELECT id, doc_id, content FROM rag_document_chunks WHERE doc_id IN (" +
|
||
",".join(chunk_placeholders) + ") ORDER BY created_at DESC LIMIT " + str(top_k))
|
||
chunk_recs = await sor.sqlExe(chunks_sql, nsq_c)
|
||
chunks_by_doc = {}
|
||
for cr in chunk_recs:
|
||
if cr.doc_id not in chunks_by_doc:
|
||
chunks_by_doc[cr.doc_id] = []
|
||
chunks_by_doc[cr.doc_id].append((cr.id, cr.content or ''))
|
||
|
||
for did, fname, kbid, fpath in doc_list[:top_k]:
|
||
chs = chunks_by_doc.get(did, [])
|
||
if chs:
|
||
for cid, ctext in chs[:2]: # up to 2 chunks per doc
|
||
hits.append({"id": cid, "doc_id": did, "score": 1.0, "text": ctext, "file_name": fname, "file_path": fpath, "kw": False})
|
||
else:
|
||
# Non-text document: render as media widget
|
||
ext = fname.rsplit('.', 1)[-1].lower() if '.' in fname else ''
|
||
hits.append({"id": did, "doc_id": did, "score": 1.0, "text": "", "file_name": fname, "file_path": fpath, "file_ext": ext, "kw": False, "is_media": True})
|
||
except Exception as e:
|
||
info(f'[search_result] tag-only lookup error: {e}')
|
||
|
||
header_text = f"🔍 检索: {query}" if query else "🔍 标签检索"
|
||
|
||
# 补全 hits 的 file_name/file_path(向量/关键词召回路径只有 chunk 信息,缺文件名)
|
||
try:
|
||
need = sorted({h.get("doc_id", "") for h in hits if h.get("doc_id") and not h.get("file_name")})
|
||
if need:
|
||
ph = []
|
||
nsq_d = {}
|
||
for i, did in enumerate(need):
|
||
ph.append("${ddid_" + str(i) + "}$")
|
||
nsq_d["ddid_" + str(i)] = did
|
||
async with get_sor_context(env, 'rag') as sor:
|
||
drecs = await sor.sqlExe(
|
||
"SELECT id, file_name, file_path FROM rag_documents WHERE id IN (" + ",".join(ph) + ")", nsq_d)
|
||
dmap = {r.id: (r.file_name or '', r.file_path or '') for r in drecs}
|
||
for h in hits:
|
||
if not h.get("file_name") and h.get("doc_id") in dmap:
|
||
fn, fp = dmap[h["doc_id"]]
|
||
h["file_name"] = fn
|
||
if not h.get("file_path"):
|
||
h["file_path"] = fp
|
||
except Exception as e:
|
||
info('[search_result] enrich file_name failed: %s' % e)
|
||
|
||
subwidgets = [
|
||
{"widgettype": "Text", "options": {"text": header_text + tag_info, "cfontsize": 18, "fontWeight": "bold", "marginBottom": "8px"}},
|
||
{"widgettype": "Text", "options": {"text": f"共 {len(hits)} 条结果" + (f" (召回 {len(raw_rows)} 条)" if raw_rows else ""), "cfontsize": 13, "color": "#888", "marginBottom": "16px"}}
|
||
]
|
||
|
||
if not hits:
|
||
subwidgets.append({"widgettype": "Text", "options": {"text": "😔 未找到相关内容", "cfontsize": 14, "color": "#aaa", "halign": "center", "marginTop": "40px"}})
|
||
else:
|
||
def safe_url(p):
|
||
if not p:
|
||
return ''
|
||
if p.startswith('/idfile'):
|
||
return p
|
||
return '/idfile' + p
|
||
|
||
video_exts = {'mp4', 'avi', 'mov', 'mkv', 'webm'}
|
||
audio_exts = {'mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac'}
|
||
image_exts = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'}
|
||
|
||
for i, h in enumerate(hits):
|
||
is_media = h.get("is_media", False)
|
||
score_pct = round(float(h["score"]) * 100, 1)
|
||
color = "#3b82f6" if score_pct > 60 else ("#10b981" if score_pct > 30 else "#f59e0b")
|
||
badges = [{"widgettype": "Text", "options": {"text": f"{score_pct}%", "cfontsize": 11, "bgcolor": color, "color": "#fff", "padding": "2px 8px", "borderRadius": "10px"}}]
|
||
if h.get("kw"):
|
||
badges.append({"widgettype": "Text", "options": {"text": "📌 关键词命中", "cfontsize": 11, "bgcolor": "#f59e0b", "color": "#fff", "padding": "2px 8px", "borderRadius": "10px", "marginLeft": "6px"}})
|
||
|
||
card_subwidgets = [
|
||
{"widgettype": "HBox", "options": {"alignItems": "center", "marginBottom": "6px"}, "subwidgets": [
|
||
{"widgettype": "Text", "options": {"text": f"#{i+1}", "cfontsize": 12, "fontWeight": "bold", "color": color, "marginRight": "8px"}},
|
||
*badges,
|
||
{"widgettype": "Text", "options": {"text": f" {h.get('file_name') or (h.get('id') or '')[:16]}", "cfontsize": 11, "color": "#999", "marginLeft": "8px"}}
|
||
]}
|
||
]
|
||
|
||
if is_media and h.get("file_ext"):
|
||
media_url = entire_url(safe_url(h.get("file_path", "")))
|
||
ext = h["file_ext"]
|
||
if ext in video_exts:
|
||
if media_url:
|
||
card_subwidgets.append({"widgettype": "Html", "options": {"html": "<video controls autoplay muted playsinline style=\"width:100%;max-height:400px\" src=\"" + media_url + "\"></video>", "padding": "4px 0"}})
|
||
elif ext in audio_exts:
|
||
if media_url:
|
||
card_subwidgets.append({"widgettype": "Html", "options": {"html": "<audio controls preload=\"metadata\" style=\"width:100%\" src=\"" + media_url + "\"></audio>", "padding": "4px 0"}})
|
||
elif ext in image_exts:
|
||
if media_url:
|
||
card_subwidgets.append({"widgettype": "Image", "options": {"url": media_url, "width": "100%", "cheight": 14, "objectFit": "contain", "bgcolor": "#f0f0f0"}})
|
||
else:
|
||
card_subwidgets.append({"widgettype": "Text", "options": {"text": f"📎 {h.get('file_name') or ''}", "cfontsize": 13, "color": "#888"}})
|
||
# Position info (bbox for images, timestamps for video/audio)
|
||
meta_info = []
|
||
bbox = h.get("bbox")
|
||
if bbox and isinstance(bbox, dict):
|
||
meta_info.append(f"📍 ({bbox.get('x1',0):.0f},{bbox.get('y1',0):.0f})-({bbox.get('x2',0):.0f},{bbox.get('y2',0):.0f})")
|
||
start_t = h.get("start_time")
|
||
end_t = h.get("end_time")
|
||
if start_t is not None or end_t is not None:
|
||
st = f"{start_t:.1f}s" if start_t is not None else "0s"
|
||
et = f"{end_t:.1f}s" if end_t is not None else ""
|
||
if et:
|
||
meta_info.append(f"⏱ {st} → {et}")
|
||
else:
|
||
meta_info.append(f"⏱ {st}")
|
||
if meta_info:
|
||
card_subwidgets.append({"widgettype": "Text", "options": {"text": " · ".join(meta_info), "cfontsize": 10, "color": "#888", "marginTop": "2px"}})
|
||
else:
|
||
card_subwidgets.append({"widgettype": "Text", "options": {"text": h["text"][:300], "cfontsize": 13, "color": "#333", "lineHeight": "1.6"}})
|
||
|
||
subwidgets.append({
|
||
"widgettype": "VBox",
|
||
"options": {"padding": "12px 16px", "marginBottom": "8px", "border": "1px solid #e0e0e0", "borderLeft": f"3px solid {color}", "bgcolor": "#fafafa", "borderRadius": "4px"},
|
||
"subwidgets": card_subwidgets
|
||
})
|
||
|
||
return json.dumps({"widgettype": "VBox", "options": {"padding": "20px", "spacing": "4px"}, "subwidgets": subwidgets}, ensure_ascii=False, default=str)
|