137 lines
5.9 KiB
Plaintext
137 lines
5.9 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 ""
|
|
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}')
|
|
|
|
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))
|
|
|
|
if not query:
|
|
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)
|
|
|
|
# 1. Embed
|
|
vec = []
|
|
try:
|
|
client = StreamHttpClient()
|
|
resp = await client.request('POST', 'https://embedding.opencomputing.net:10443/api/embed',
|
|
json={"texts": [query], "model": "CLIP-ViT-H-14"})
|
|
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
|
|
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 = []
|
|
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 = ''
|
|
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}$",
|
|
{"id": rid})
|
|
if recs:
|
|
chunk_text = recs[0].content or ''
|
|
doc_id = recs[0].doc_id or doc_id
|
|
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:
|
|
hits.append(kr)
|
|
seen.add(kr["id"])
|
|
|
|
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"共 {len(hits)} 条结果 (召回 {len(raw_rows)} 条)", "cfontsize": 13, "color": "#888", "marginBottom": "16px"}}
|
|
]
|
|
|
|
if not hits:
|
|
subwidgets.append({"widgettype": "Text", "options": {"text": "😔 未找到相关内容", "cfontsize": 14, "color": "#aaa", "halign": "center", "marginTop": "40px"}})
|
|
else:
|
|
for i, h in enumerate(hits):
|
|
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"}})
|
|
subwidgets.append({
|
|
"widgettype": "VBox",
|
|
"options": {"padding": "12px 16px", "marginBottom": "8px", "border": "1px solid #e0e0e0", "borderLeft": f"3px solid {color}", "bgcolor": "#fafafa", "borderRadius": "4px"},
|
|
"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['id'][:16]}", "cfontsize": 11, "color": "#999", "marginLeft": "8px"}}
|
|
]},
|
|
{"widgettype": "Text", "options": {"text": h["text"][:300], "cfontsize": 13, "color": "#333", "lineHeight": "1.6"}}
|
|
]
|
|
})
|
|
|
|
return json.dumps({"widgettype": "VBox", "options": {"padding": "20px", "spacing": "4px"}, "subwidgets": subwidgets}, ensure_ascii=False, default=str)
|