Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/1622359590_ai-cs-hub.json
T

1 line
181 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "1622359590/ai-cs-hub", "files": {"backend/services/rag.js": {"ff8d17b14023e5654fa002ec3900c26e454165c2": {"t": 1785746671.4737246, "n": 23110, "h": "d181ea2ca2052cde", "c": "/**\n * RAG 检索服务 — 混合检索(BM25 + 向量)+ Re-ranking\n * 向量检索使用真实 Embedding API(DeepSeek / OpenAI 兼容)\n */\nconst { getDb } = require('../database/schema');\n\nlet sqliteVecLoaded = false;\nfunction ensureVec(db) {\n if (sqliteVecLoaded) return;\n try {\n const sqliteVec = require('sqlite-vec');\n sqliteVec.load(db);\n sqliteVecLoaded = true;\n } catch (e) {\n console.warn('sqlite-vec 加载失败:', e.message);\n }\n}\n\n// Embedding 配置\nconst EMBEDDING_DIMENSION = 512; // bge-small-zh-v1.5 输出维度\nconst EMBEDDING_BATCH_SIZE = 10; // DashScope 限制每批最多 10 条\n\n// 命名空间前缀(用于 chunk ID,避免冲突)\nconst NS = { knowledge: 'k', tutorial: 't', faq: 'f' };\n\n// Embedding 模型映射\nconst EMBEDDING_MODELS = {\n deepseek: 'deepseek-embedding',\n openai: 'text-embedding-3-small',\n qwen: 'text-embedding-v3',\n};\n\n// Provider 对应的 Embedding API URL\nfunction getEmbeddingUrl(provider) {\n const urls = {\n deepseek: 'https://api.deepseek.com/embeddings',\n openai: 'https://api.openai.com/v1/embeddings',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings',\n };\n return urls[provider] || '';\n}\n\n// BM25 参数\nconst BM25_K1 = 1.5;\nconst BM25_B = 0.75;\n\n// 同义词映射(用于查询扩展)\nconst SYNONYMS = {\n '工单': ['工单', '工单', '问题', '反馈', '需求'],\n '教程': ['教程', '课程', '学习', '视频'],\n '客服': ['客服', '人工', '帮助', '支持'],\n};\n\n// 中文停用词\nconst STOP_WORDS = new Set([\n '的', '了', '是', '在', '我', '有', '和', '就', '不', '人', '都', '一',\n '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有',\n '看', '好', '自己', '这', '他', '么', '把', '那', '你', '它', '她', '哪',\n '如何', '怎么', '什么', '为什么', '可以', '能', '可以', '请', '求', '帮',\n]);\n\n/**\n * 分词(简单实现:中文逐字 + 英文按空格)\n */\nfunction tokenize(text) {\n if (!text) return [];\n const tokens = [];\n // 匹配中文字符或英文单词\n const matches = text.match(/[\\u4e00-\\u9fff]|[a-zA-Z0-9]+/g) || [];\n for (const m of matches) {\n const lower = m.toLowerCase();\n if (!STOP_WORDS.has(lower) && lower.length > 0) {\n tokens.push(lower);\n }\n }\n return tokens;\n}\n\n/**\n * 查询扩展:添加同义词\n */\nfunction expandQuery(query) {\n let expanded = query;\n for (const [key, synonyms] of Object.entries(SYNONYMS)) {\n if (query.includes(key)) {\n expanded += ' ' + synonyms.join(' ');\n }\n }\n return expanded;\n}\n\n// ===== BM25 索引 =====\nclass BM25Index {\n constructor() {\n this.docs = [];\n this.docCount = 0;\n this.avgDocLen = 0;\n this.docFreq = {}; // term -> 文档频率\n this.docLens = [];\n this.invertedIndex = {}; // term -> [{docIdx, tf}]\n }\n\n build(items) {\n this.docs = items;\n this.docCount = items.length;\n this.docFreq = {};\n this.invertedIndex = {};\n this.docLens = [];\n\n let totalLen = 0;\n for (let i = 0; i < items.length; i++) {\n const tokens = tokenize(items[i].title + ' ' + items[i].content);\n this.docLens.push(tokens.length);\n totalLen += tokens.length;\n\n // 统计词频\n const tf = {};\n for (const t of tokens) tf[t] = (tf[t] || 0) + 1;\n\n // 构建倒排索引\n for (const [term, freq] of Object.entries(tf)) {\n if (!this.invertedIndex[term]) this.invertedIndex[term] = [];\n this.invertedIndex[term].push({ docIdx: i, tf: freq });\n this.docFreq[term] = (this.docFreq[term] || 0) + 1;\n }\n }\n this.avgDocLen = totalLen / this.docCount || 1;\n }\n\n search(query, topK = 5) {\n const expandedQuery = expandQuery(query);\n const queryTokens = tokenize(expandedQuery);\n if (queryTokens.length === 0) return [];\n\n const scores = new Array(this.docCount).fill(0);\n\n for (const term of queryTokens) {\n const postings = this.invertedIndex[term] || [];\n const df = this.docFreq[term] || 0;\n if (df === 0) continue;\n\n // IDF 部分\n const idf = Math.log((this.docCount - df + 0.5) / (df + 0.5) + 1);\n\n for (const { docIdx, tf } of postings) {\n const docLen = this.docLens[docIdx];\n // BM25 公式\n const tfNorm = (tf * (BM25_K1 + 1)) / (tf + BM25_K1 * (1 - BM25_B + BM25_B * docLen / this.avgDocLen));\n scores[docIdx] += idf * tfNorm;\n }\n }\n\n // 排序取 Top-K\n const results = [];\n for (let i = 0; i < this.docCount; i++) {\n if (scores[i] > 0) {\n results.push({ id: this.docs[i].id, score: scores[i] });\n }\n }\n results.sort((a, b) => b.score - a.score);\n return results.slice(0, topK);\n }\n}\n\n/**\n * 调用 Embedding API(异步,使用 fetch)\n * @param {string} text - 要嵌入的文本\n * @param {object} config - {provider, apiKey, baseUrl, model}\n * @returns {Promise<Float32Array|null>} 嵌入向量\n */\nasync function callEmbeddingAPI(text, config) {\n const url = config.baseUrl || getEmbeddingUrl(config.provider);\n if (!url || !config.apiKey) return null;\n\n const model = config.model || EMBEDDING_MODELS[config.provider] || 'deepseek-embedding';\n\n try {\n const resp = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${config.apiKey}`,\n },\n body: JSON.stringify({\n model,\n input: text.slice(0, 8000),\n }),\n signal: AbortSignal.timeout(30000),\n });\n\n if (!resp.ok) {\n console.warn('Embedding API HTTP 错误:', resp.status);\n return null;\n }\n\n const json = await resp.json();\n if (json.data && json.data[0] && json.data[0].embedding) {\n return new Float32Array(json.data[0].embedding);\n }\n if (json.error) {\n console.warn('Embedding API 错误:', json.error.message || json.error);\n }\n return null;\n } catch (e) {\n console.warn('Embedding API 调用失败:', e.message);\n return null;\n }\n}\n\n/**\n * 批量调用 Embedding API(异步)\n * @param {string[]} texts - 文本数组\n * @param {object} config - API 配置\n * @returns {Promise<Float32Array[]>} 嵌入向量数组(失败的用 null 占位)\n */\nasync function callEmbeddingBatch(texts, config) {\n const url = config.baseUrl || getEmbeddingUrl(config.provider);\n if (!url || !config.apiKey) return texts.map(() => null);\n\n const model = config.model || EMBEDDING_MODELS[config.provider] || 'deepseek-embedding';\n const results = new Array(texts.length).fill(null);\n\n // 分批处理\n for (let i = 0; i < texts.length; i += EMBEDDING_BATCH_SIZE) {\n const batch = texts.slice(i, i + EMBEDDING_BATCH_SIZE);\n\n try {\n const resp = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${config.apiKey}`,\n },\n body: JSON.stringify({\n model,\n input: batch.map(t => t.slice(0, 8000)),\n }),\n signal: AbortSignal.timeout(60000),\n });\n\n if (!resp.ok) {\n console.warn('Embedding 批量 API HTTP 错误:', resp.status);\n continue;\n }\n\n const json = await resp.json();\n if (json.data) {\n for (const item of json.data) {\n results[i + item.index] = new Float32Array(item.embedding);\n }\n }\n if (json.error) {\n console.warn('Embedding 批量 API 错误:', json.error.message || json.error);\n }\n } catch (e) {\n console.warn('Embedding 批量 API 调用失败:', e.message);\n }\n\n // 批次间隔,避免限流\n if (i + EMBEDDING_BATCH_SIZE < texts.length) {\n await new Promise(r => setTimeout(r, 1000));\n }\n }\n\n return results;\n}\n\n/**\n * 从 settings 读取 Embedding 配置\n * 支持独立的 embedding 配置,回退到通用 ai 配置\n */\nfunction getEmbeddingConfig() {\n const db = getDb();\n const rows = db.prepare(\n \"SELECT key, value FROM settings WHERE key IN ('embedding_provider','embedding_api_key','embedding_base_url','embedding_model','ai_provider','ai_api_key','ai_base_url')\"\n ).all();\n const config = {};\n for (const row of rows) config[row.key] = row.value;\n return {\n provider: config.embedding_provider || config.ai_provider || 'deepseek',\n apiKey: config.embedding_api_key || config.ai_api_key || '',\n baseUrl: config.embedding_base_url || '',\n model: config.embedding_model || '',\n };\n}\n\n// ===== 向量索引(使用 sqlite-vec)=====\n// 向量表用自增整数 ID,chunkMeta 映射 vectorId → {source, parentId, chunkIndex}\nlet vectorIdCounter = 0;\nlet chunkMeta = {}; // vectorId → {source, parentId, chunkIndex, chunkId}\nlet chunkIdToVectorId = {}; // chunkId → vectorId\n\nclass VectorIndex {\n constructor() {\n this.dimension = EMBEDDING_DIMENSION;\n }\n\n initTable(db) {\n ensureVec(db);\n db.exec(`\n CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_vec USING vec0(\n id INTEGER PRIMARY KEY,\n embedding float[${this.dimension}]\n );\n `);\n }\n\n rebuildTable(db) {\n ensureVec(db);\n try {\n const info = db.prepare(\"SELECT sql FROM sqlite_master WHERE name = 'knowledge_vec'\").get();\n if (info && !info.sql.includes(`float[${this.dimension}]`)) {\n console.log(`向量表维度变化,重建表 (${this.dimension}维)`);\n db.prepare('DROP TABLE IF EXISTS knowledge_vec').run();\n }\n } catch {}\n this.initTable(db);\n }\n\n upsert(db, vectorId, embedding) {\n if (!embedding) return false;\n try {\n db.prepare('DELETE FROM knowledge_vec WHERE id = ?').run(vectorId);\n db.prepare('INSERT INTO knowledge_vec (id, embedding) VALUES (?, ?)').run(\n vectorId,\n Buffer.from(embedding.buffer)\n );\n return true;\n } catch (e) {\n return false;\n }\n }\n\n search(db, queryVector, topK = 5) {\n ensureVec(db);\n if (!queryVector) return [];\n try {\n const results = db.prepare(`\n SELECT id, distance FROM knowledge_vec WHERE embedding MATCH ? ORDER BY distance LIMIT ?\n `).all(Buffer.from(queryVector.buffer), topK);\n return results.map(r => ({ vectorId: r.id, score: 1 - r.distance }));\n } catch (e) {\n return [];\n }\n }\n}\n\n// 全局实例\nlet bm25Index = null;\nconst vectorIndex = new VectorIndex();\n\n// ===== 文档拆分 (Chunking) =====\nconst CHUNK_MAX_CHARS = 400;\nconst CHUNK_OVERLAP = 50;\n\n/**\n * 将长文本按段落拆分成小块\n */\nfunction chunkText(text, maxChars = CHUNK_MAX_CHARS) {\n if (!text || text.length <= maxChars) return [text];\n\n const chunks = [];\n const paragraphs = text.split(/\\n\\s*\\n/).filter(p => p.trim());\n\n let current = '';\n for (const para of paragraphs) {\n if (current.length + para.length + 1 <= maxChars) {\n current = current ? current + '\\n\\n' + para : para;\n } else {\n if (current) chunks.push(current);\n if (para.length > maxChars) {\n const sentences = para.split(/(?<=[。!?.!?\\n])/).filter(s => s.trim());\n let sentBuf = '';\n for (const sent of sentences) {\n if (sentBuf.length + sent.length <= maxChars) {\n sentBuf += sent;\n } else {\n if (sentBuf) chunks.push(sentBuf);\n sentBuf = sent;\n }\n }\n if (sentBuf) current = sentBuf;\n else current = '';\n } else {\n current = para;\n }\n }\n }\n if (current) chunks.push(current);\n\n if (CHUNK_OVERLAP > 0 && chunks.length > 1) {\n const overlapped = [chunks[0]];\n for (let i = 1; i < chunks.length; i++) {\n const prevTail = chunks[i - 1].slice(-CHUNK_OVERLAP);\n overlapped.push(prevTail + chunks[i]);\n }\n return overlapped;\n }\n\n return chunks;\n}\n\n/**\n * 将知识条目拆分成可索引的块\n * 使用字符串复合 ID: \"{source}:{parentId}:{chunkIndex}\" 避免命名空间冲突\n */\nfunction chunkItem(item) {\n const fullText = item.content || '';\n const ns = NS[item.source] || 'k';\n\n if (fullText.length <= CHUNK_MAX_CHARS) {\n return [{\n chunkId: `${ns}:${item.id}:0`,\n source: item.source,\n parentId: item.id,\n chunkIndex: 0,\n title: item.title,\n content: item.title + ' ' + fullText,\n category: item.category,\n }];\n }\n\n const chunks = chunkText(fullText);\n return chunks.map((chunk, i) => ({\n chunkId: `${ns}:${item.id}:${i}`,\n source: item.source,\n parentId: item.id,\n chunkIndex: i,\n title: item.title + (chunks.length > 1 ? ` (第${i + 1}/${chunks.length}部分)` : ''),\n content: item.title + ' ' + chunk,\n category: item.category,\n }));\n}\n\n// 块内容缓存(chunkId → {title, content, category, source, parentId})\nlet chunkContentCache = {};\n\n/**\n * 重建索引(异步)\n * @param {boolean} forceVectors - 强制重建向量\n * @returns {Promise<number>} 索引条目数\n */\nasync function rebuildIndex(forceVectors = false) {\n const db = getDb();\n\n // 1. ai_knowledge 表\n const knowledgeItems = db.prepare(\n \"SELECT id, title, content, category FROM ai_knowledge WHERE status = 'active'\"\n ).all().map(item => ({ ...item, source: 'knowledge' }));\n\n // 2. tutorials 表(已发布教程)\n const tutorialItems = db.prepare(\n \"SELECT id, title, content, category FROM tutorials WHERE status = 'published'\"\n ).all().map(item => ({ ...item, source: 'tutorial' }));\n\n // 3. faqs 表(激活状态)\n const faqItems = db.prepare(\n \"SELECT id, question as title, answer as content, category FROM faqs WHERE status = 'active'\"\n ).all().map(item => ({ ...item, source: 'faq' }));\n\n // 合并所有数据源\n const rawItems = [...knowledgeItems, ...tutorialItems, ...faqItems];\n\n // 拆分长文档\n const allItems = [];\n let chunkCount = 0;\n for (const item of rawItems) {\n const chunks = chunkItem(item);\n allItems.push(...chunks);\n if (chunks.length > 1) chunkCount++;\n }\n if (chunkCount > 0) {\n console.log(`文档拆分: ${chunkCount} 篇长文档被拆分成 ${allItems.length} 个块`);\n }\n\n // 更新块内容缓存\n chunkContentCache = {};\n for (const item of allItems) {\n chunkContentCache[item.chunkId] = {\n title: item.title,\n content: item.content,\n category: item.category,\n source: item.source,\n parentId: item.parentId,\n };\n }\n\n // 重建 BM25\n bm25Index = new BM25Index();\n bm25Index.build(allItems.map(item => ({ ...item, id: item.chunkId })));\n\n // 重建向量(使用真实 Embedding API)\n try {\n vectorIndex.rebuildTable(db);\n\n const currentVecCount = db.prepare('SELECT COUNT(*) as cnt FROM knowledge_vec').get().cnt;\n if (!forceVectors && currentVecCount >= allItems.length) {\n // 跳过向量重建,但仍需恢复内存映射(重启后 chunkMeta 为空)\n vectorIdCounter = 0;\n chunkMeta = {};\n chunkIdToVectorId = {};\n for (let i = 0; i < allItems.length; i++) {\n const vecId = ++vectorIdCounter;\n chunkMeta[vecId] = {\n source: allItems[i].source,\n parentId: allItems[i].parentId,\n chunkIndex: allItems[i].chunkIndex,\n chunkId: allItems[i].chunkId,\n };\n chunkIdToVectorId[allItems[i].chunkId] = vecId;\n }\n console.log(`向量索引已存在 (${currentVecCount} 条),恢复内存映射`);\n } else {\n try { db.prepare('DELETE FROM knowledge_vec').run(); } catch {}\n\n // 重置映射\n vectorIdCounter = 0;\n chunkMeta = {};\n chunkIdToVectorId = {};\n\n const texts = allItems.map(item => item.title + ' ' + item.content);\n const embedConfig = getEmbeddingConfig();\n\n if (embedConfig.apiKey) {\n console.log(`正在调用 ${embedConfig.provider} Embedding API,共 ${texts.length} 条...`);\n const embeddings = await callEmbeddingBatch(texts, embedConfig);\n\n let successCount = 0;\n for (let i = 0; i < allItems.length; i++) {\n const vecId = ++vectorIdCounter;\n if (embeddings[i] && vectorIndex.upsert(db, vecId, embeddings[i])) {\n chunkMeta[vecId] = {\n source: allItems[i].source,\n parentId: allItems[i].parentId,\n chunkIndex: allItems[i].chunkIndex,\n chunkId: allItems[i].chunkId,\n };\n chunkIdToVectorId[allItems[i].chunkId] = vecId;\n successCount++;\n }\n }\n console.log(`向量索引已建立: ${successCount}/${allItems.length} 条成功`);\n } else {\n console.warn('未配置 AI API Key,跳过向量索引构建(仅使用 BM25)');\n }\n }\n } catch (e) {\n console.warn('向量索引初始化失败(不影响 BM25 检索):', e.message);\n }\n\n console.log(`RAG 索引已重建: ${allItems.length} 条 (知识库${knowledgeItems.length} + 教程${tutorialItems.length} + FAQ${faqItems.length})`);\n return allItems.length;\n}\n\n/**\n * 混合检索:BM25 + 向量,加权合并\n * @param {string} query - 用户问题\n * @param {number} topK - 返回条数\n * @returns {Promise<Array>} [{chunkId, title, content, score, source, parentId}]\n */\nasync function retrieve(query, topK = 5) {\n const db = getDb();\n\n if (!bm25Index) await rebuildIndex();\n\n // BM25 检索\n const bm25Results = bm25Index.search(query, topK * 2);\n\n // 向量检索\n let vecResults = [];\n try {\n const embedConfig = getEmbeddingConfig();\n if (embedConfig.apiKey) {\n const queryVector = await callEmbeddingAPI(query, embedConfig);\n if (queryVector) {\n const rawVecResults = vectorIndex.search(db, queryVector, topK * 2);\n // 将 vectorId 映射回 chunkId\n for (const r of rawVecResults) {\n const meta = chunkMeta[r.vectorId];\n if (meta) {\n vecResults.push({ id: meta.chunkId, score: r.score });\n }\n }\n }\n }\n } catch {}\n\n // 合并分数(加权:BM25 0.6 + 向量 0.4)\n const scoreMap = {};\n for (const r of bm25Results) {\n scoreMap[r.id] = (scoreMap[r.id] || 0) + r.score * 0.6;\n }\n for (const r of vecResults) {\n scoreMap[r.id] = (scoreMap[r.id] || 0) + r.score * 0.4;\n }\n\n const sorted = Object.entries(scoreMap)\n .map(([id, score]) => ({ chunkId: id, score }))\n .sort((a, b) => b.score - a.score)\n .slice(0, topK);\n\n if (sorted.length === 0) return [];\n\n // 获取详情\n const itemMap = {};\n const uncachedItems = [];\n\n for (const s of sorted) {\n if (chunkContentCache[s.chunkId]) {\n itemMap[s.chunkId] = chunkContentCache[s.chunkId];\n } else {\n uncachedItems.push(s);\n }\n }\n\n // 缓存未命中的,按来源查数据库\n const knowledgeParents = new Set();\n const tutorialParents = new Set();\n const faqParents = new Set();\n\n for (const s of uncachedItems) {\n const [source, parentId] = s.chunkId.split(':');\n const pid = Number(parentId);\n if (source === 'k') knowledgeParents.add(pid);\n else if (source === 't') tutorialParents.add(pid);\n else if (source === 'f') faqParents.add(pid);\n }\n\n if (knowledgeParents.size > 0) {\n const ids = [...knowledgeParents];\n const ph = ids.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, title, content, category FROM ai_knowledge WHERE id IN (${ph}) AND status = 'active'`).all(...ids);\n for (const r of rows) {\n itemMap[`k:${r.id}:0`] = { ...r, source: 'knowledge', parentId: r.id };\n }\n }\n if (tutorialParents.size > 0) {\n const ids = [...tutorialParents];\n const ph = ids.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, title, content, category FROM tutorials WHERE id IN (${ph}) AND status = 'published'`).all(...ids);\n for (const r of rows) {\n itemMap[`t:${r.id}:0`] = { ...r, source: 'tutorial', parentId: r.id };\n }\n }\n if (faqParents.size > 0) {\n const ids = [...faqParents];\n const ph = ids.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, question as title, answer as content, category FROM faqs WHERE id IN (${ph}) AND status = 'active'`).all(...ids);\n for (const r of rows) {\n itemMap[`f:${r.id}:0`] = { ...r, source: 'faq', parentId: r.id };\n }\n }\n\n const results = sorted\n .filter(s => itemMap[s.chunkId])\n .map(s => {\n const item = itemMap[s.chunkId];\n const images = [];\n const content = item.content || '';\n const mdImgs = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^)]+)\\)/g) || [];\n for (const m of mdImgs) {\n const url = m.match(/\\((https?:\\/\\/[^)]+)\\)/);\n if (url) images.push(url[1]);\n }\n const htmlImgs = content.match(/<img[^>]+src=[\"'](https?:\\/\\/[^\"']+)[\"'][^>]*>/gi) || [];\n for (const m of htmlImgs) {\n const url = m.match(/src=[\"'](https?:\\/\\/[^\"']+)[\"']/i);\n if (url) images.push(url[1]);\n }\n return {\n title: item.title,\n content: item.content,\n category: item.category,\n source: item.source,\n parentId: item.parentId,\n score: s.score,\n images: [...new Set(images)],\n };\n });\n\n // 更新知识库命中次数\n try {\n const knowledgeIds = results.filter(r => r.source === 'knowledge').map(r => r.parentId);\n if (knowledgeIds.length > 0) {\n const placeholders = knowledgeIds.map(() => '?').join(',');\n db.prepare(`UPDATE ai_knowledge SET hit_count = hit_count + 1 WHERE id IN (${placeholders})`).run(...knowledgeIds);\n }\n } catch {}\n\n return results;\n}\n\n/**\n * 计算两个文本的相似度\n */\nfunction similarity(textA, textB) {\n const tokensA = new Set(tokenize(textA));\n const tokensB = new Set(tokenize(textB));\n if (tokensA.size === 0 || tokensB.size === 0) return 0;\n let overlap = 0;\n for (const t of tokensA) {\n if (tokensB.has(t)) overlap++;\n }\n const union = new Set([...tokensA, ...tokensB]).size;\n return union > 0 ? overlap / union : 0;\n}\n\n/**\n * 查找与给定问题相似的已有自动学习条目\n */\nasync function findSimilarLearned(question, threshold = 2.0) {\n const db = getDb();\n\n if (!bm25Index) await rebuildIndex();\n\n const bm25Results = bm25Index.search(question, 10);\n\n for (const result of bm25Results) {\n if (result.score < threshold) break;\n\n // chunkId 格式: \"k:{parentId}:{chunkIndex}\"\n const parts = result.id.split(':');\n const parentId = Number(parts[1]);\n\n const item = db.prepare(\n \"SELECT id, title, content FROM ai_knowledge WHERE id = ? AND category = '自动学习' AND status = 'active'\"\n ).get(parentId);\n\n if (item) {\n return { ...item, score: result.score };\n }\n }\n\n return null;\n}\n\n/**\n * 清理自动学习条目\n */\nfunction cleanupLearned(maxCount = 200) {\n const db = getDb();\n const items = db.prepare(\n \"SELECT id, title, content FROM ai_knowledge WHERE category = '自动学习' AND status = 'active' ORDER BY updated_at DESC\"\n ).all();\n\n if (items.length === 0) return { merged: 0, removed: 0 };\n\n let merged = 0;\n const toDelete = new Set();\n\n for (let i = 0; i < items.length; i++) {\n if (toDelete.has(items[i].id)) continue;\n for (let j = i + 1; j < items.length; j++) {\n if (toDelete.has(items[j].id)) continue;\n const score = similarity(items[i].title, items[j].title);\n if (score >= 0.4) {\n const existingContent = items[i].content;\n const newAnswer = items[j].content.replace(/^用户问题:.*?\\n\\n参考回答:/s, '');\n if (newAnswer && !existingContent.includes(newAnswer.slice(0, 50))) {\n db.prepare('UPDATE ai_knowledge SET content = content || ? WHERE id = ?')\n .run('\\n\\n补充回答:' + newAnswer, items[i].id);\n }\n toDelete.add(items[j].id);\n merged++;\n }\n }\n }\n\n if (toDelete.size > 0) {\n const placeholders = Array.from(toDelete).map(() => '?').join(',');\n db.prepare(`DELETE FROM ai_knowledge WHERE id IN (${placeholders})`).run(...toDelete);\n }\n\n let removed = 0;\n const remaining = db.prepare(\n \"SELECT COUNT(*) as cnt FROM ai_knowledge WHERE category = '自动学习' AND status = 'active'\"\n ).get().cnt;\n\n if (remaining > maxCount) {\n const excess = remaining - maxCount;\n const oldItems = db.prepare(\n \"SELECT id FROM ai_knowledge WHERE category = '自动学习' AND status = 'active' ORDER BY updated_at ASC LIMIT ?\"\n ).all(excess);\n if (oldItems.length > 0) {\n const placeholders = oldItems.map(() => '?').join(',');\n db.prepare(`DELETE FROM ai_knowledge WHERE id IN (${placeholders})`).run(...oldItems.map(i => i.id));\n removed = oldItems.length;\n }\n }\n\n return { merged, removed };\n}\n\nmodule.exports = { retrieve, rebuildIndex, tokenize, similarity, findSimilarLearned, cleanupLearned };\n"}, "54c84ea54c3a90233572722cf5e1305947594777": {"t": 1785766725.6997025, "n": 23110, "h": "d9b588fd40ec18f2", "c": "/**\n * RAG 检索服务 — 混合检索(BM25 + 向量)+ Re-ranking\n * 向量检索使用真实 Embedding API(DeepSeek / OpenAI 兼容)\n */\nconst { getDb } = require('../database/schema');\n\nlet sqliteVecLoaded = false;\nfunction ensureVec(db) {\n if (sqliteVecLoaded) return;\n try {\n const sqliteVec = require('sqlite-vec');\n sqliteVec.load(db);\n sqliteVecLoaded = true;\n } catch (e) {\n console.warn('sqlite-vec 加载失败:', e.message);\n }\n}\n\n// Embedding 配置\nconst EMBEDDING_DIMENSION = 512; // bge-small-zh-v1.5 输出维度\nconst EMBEDDING_BATCH_SIZE = 10; // DashScope 限制每批最多 10 条\n\n// 命名空间前缀(用于 chunk ID,避免冲突)\nconst NS = { knowledge: 'k', tutorial: 't', faq: 'f' };\n\n// Embedding 模型映射\nconst EMBEDDING_MODELS = {\n deepseek: 'deepseek-embedding',\n openai: 'text-embedding-3-small',\n qwen: 'text-embedding-v3',\n};\n\n// Provider 对应的 Embedding API URL\nfunction getEmbeddingUrl(provider) {\n const urls = {\n deepseek: 'https://api.deepseek.com/embeddings',\n openai: 'https://api.openai.com/v1/embeddings',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings',\n };\n return urls[provider] || '';\n}\n\n// BM25 参数\nconst BM25_K1 = 1.5;\nconst BM25_B = 0.75;\n\n// 同义词映射(用于查询扩展)\nconst SYNONYMS = {\n '工单': ['工单', '工单', '问题', '反馈', '需求'],\n '教程': ['教程', '课程', '学习', '视频'],\n '客服': ['客服', '人工', '帮助', '支持'],\n};\n\n// 中文停用词\nconst STOP_WORDS = new Set([\n '的', '了', '是', '在', '我', '有', '和', '就', '不', '人', '都', '一',\n '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有',\n '看', '好', '自己', '这', '他', '么', '把', '那', '你', '它', '她', '哪',\n '如何', '怎么', '什么', '为什么', '可以', '能', '可以', '请', '求', '帮',\n]);\n\n/**\n * 分词(简单实现:中文逐字 + 英文按空格)\n */\nfunction tokenize(text) {\n if (!text) return [];\n const tokens = [];\n // 匹配中文字符或英文单词\n const matches = text.match(/[\\u4e00-\\u9fff]|[a-zA-Z0-9]+/g) || [];\n for (const m of matches) {\n const lower = m.toLowerCase();\n if (!STOP_WORDS.has(lower) && lower.length > 0) {\n tokens.push(lower);\n }\n }\n return tokens;\n}\n\n/**\n * 查询扩展:添加同义词\n */\nfunction expandQuery(query) {\n let expanded = query;\n for (const [key, synonyms] of Object.entries(SYNONYMS)) {\n if (query.includes(key)) {\n expanded += ' ' + synonyms.join(' ');\n }\n }\n return expanded;\n}\n\n// ===== BM25 索引 =====\nclass BM25Index {\n constructor() {\n this.docs = [];\n this.docCount = 0;\n this.avgDocLen = 0;\n this.docFreq = {}; // term -> 文档频率\n this.docLens = [];\n this.invertedIndex = {}; // term -> [{docIdx, tf}]\n }\n\n build(items) {\n this.docs = items;\n this.docCount = items.length;\n this.docFreq = {};\n this.invertedIndex = {};\n this.docLens = [];\n\n let totalLen = 0;\n for (let i = 0; i < items.length; i++) {\n const tokens = tokenize(items[i].title + ' ' + items[i].content);\n this.docLens.push(tokens.length);\n totalLen += tokens.length;\n\n // 统计词频\n const tf = {};\n for (const t of tokens) tf[t] = (tf[t] || 0) + 1;\n\n // 构建倒排索引\n for (const [term, freq] of Object.entries(tf)) {\n if (!this.invertedIndex[term]) this.invertedIndex[term] = [];\n this.invertedIndex[term].push({ docIdx: i, tf: freq });\n this.docFreq[term] = (this.docFreq[term] || 0) + 1;\n }\n }\n this.avgDocLen = totalLen / this.docCount || 1;\n }\n\n search(query, topK = 5) {\n const expandedQuery = expandQuery(query);\n const queryTokens = tokenize(expandedQuery);\n if (queryTokens.length === 0) return [];\n\n const scores = new Array(this.docCount).fill(0);\n\n for (const term of queryTokens) {\n const postings = this.invertedIndex[term] || [];\n const df = this.docFreq[term] || 0;\n if (df === 0) continue;\n\n // IDF 部分\n const idf = Math.log((this.docCount - df + 0.5) / (df + 0.5) + 1);\n\n for (const { docIdx, tf } of postings) {\n const docLen = this.docLens[docIdx];\n // BM25 公式\n const tfNorm = (tf * (BM25_K1 + 1)) / (tf + BM25_K1 * (1 - BM25_B + BM25_B * docLen / this.avgDocLen));\n scores[docIdx] += idf * tfNorm;\n }\n }\n\n // 排序取 Top-K\n const results = [];\n for (let i = 0; i < this.docCount; i++) {\n if (scores[i] > 0) {\n results.push({ id: this.docs[i].id, score: scores[i] });\n }\n }\n results.sort((a, b) => b.score - a.score);\n return results.slice(0, topK);\n }\n}\n\n/**\n * 调用 Embedding API(异步,使用 fetch)\n * @param {string} text - 要嵌入的文本\n * @param {object} config - {provider, apiKey, baseUrl, model}\n * @returns {Promise<Float32Array|null>} 嵌入向量\n */\nasync function callEmbeddingAPI(text, config) {\n const url = config.baseUrl || getEmbeddingUrl(config.provider);\n if (!url || !config.apiKey) return null;\n\n const model = config.model || EMBEDDING_MODELS[config.provider] || 'deepseek-embedding';\n\n try {\n const resp = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${config.apiKey}`,\n },\n body: JSON.stringify({\n model,\n input: text.slice(0, 8000),\n }),\n signal: AbortSignal.timeout(30000),\n });\n\n if (!resp.ok) {\n console.warn('Embedding API HTTP 错误:', resp.status);\n return null;\n }\n\n const json = await resp.json();\n if (json.data && json.data[0] && json.data[0].embedding) {\n return new Float32Array(json.data[0].embedding);\n }\n if (json.error) {\n console.warn('Embedding API 错误:', json.error.message || json.error);\n }\n return null;\n } catch (e) {\n console.warn('Embedding API 调用失败:', e.message);\n return null;\n }\n}\n\n/**\n * 批量调用 Embedding API(异步)\n * @param {string[]} texts - 文本数组\n * @param {object} config - API 配置\n * @returns {Promise<Float32Array[]>} 嵌入向量数组(失败的用 null 占位)\n */\nasync function callEmbeddingBatch(texts, config) {\n const url = config.baseUrl || getEmbeddingUrl(config.provider);\n if (!url || !config.apiKey) return texts.map(() => null);\n\n const model = config.model || EMBEDDING_MODELS[config.provider] || 'deepseek-embedding';\n const results = new Array(texts.length).fill(null);\n\n // 分批处理\n for (let i = 0; i < texts.length; i += EMBEDDING_BATCH_SIZE) {\n const batch = texts.slice(i, i + EMBEDDING_BATCH_SIZE);\n\n try {\n const resp = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${config.apiKey}`,\n },\n body: JSON.stringify({\n model,\n input: batch.map(t => t.slice(0, 8000)),\n }),\n signal: AbortSignal.timeout(60000),\n });\n\n if (!resp.ok) {\n console.warn('Embedding 批量 API HTTP 错误:', resp.status);\n continue;\n }\n\n const json = await resp.json();\n if (json.data) {\n for (const item of json.data) {\n results[i + item.index] = new Float32Array(item.embedding);\n }\n }\n if (json.error) {\n console.warn('Embedding 批量 API 错误:', json.error.message || json.error);\n }\n } catch (e) {\n console.warn('Embedding 批量 API 调用失败:', e.message);\n }\n\n // 批次间隔,避免限流\n if (i + EMBEDDING_BATCH_SIZE < texts.length) {\n await new Promise(r => setTimeout(r, 1000));\n }\n }\n\n return results;\n}\n\n/**\n * 从 settings 读取 Embedding 配置\n * 支持独立的 embedding 配置,回退到通用 ai 配置\n */\nfunction getEmbeddingConfig() {\n const db = getDb();\n const rows = db.prepare(\n \"SELECT key, value FROM settings WHERE key IN ('embedding_provider','embedding_api_key','embedding_base_url','embedding_model','ai_provider','ai_api_key','ai_base_url')\"\n ).all();\n const config = {};\n for (const row of rows) config[row.key] = row.value;\n return {\n provider: config.embedding_provider || config.ai_provider || 'deepseek',\n apiKey: config.embedding_api_key || config.ai_api_key || '',\n baseUrl: config.embedding_base_url || '',\n model: config.embedding_model || '',\n };\n}\n\n// ===== 向量索引(使用 sqlite-vec)=====\n// 向量表用自增整数 ID,chunkMeta 映射 vectorId → {source, parentId, chunkIndex}\nlet vectorIdCounter = 0;\nlet chunkMeta = {}; // vectorId → {source, parentId, chunkIndex, chunkId}\nlet chunkIdToVectorId = {}; // chunkId → vectorId\n\nclass VectorIndex {\n constructor() {\n this.dimension = EMBEDDING_DIMENSION;\n }\n\n initTable(db) {\n ensureVec(db);\n db.exec(`\n CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_vec USING vec0(\n id INTEGER PRIMARY KEY,\n embedding float[${this.dimension}]\n );\n `);\n }\n\n rebuildTable(db) {\n ensureVec(db);\n try {\n const info = db.prepare(\"SELECT sql FROM sqlite_master WHERE name = 'knowledge_vec'\").get();\n if (info && !info.sql.includes(`float[${this.dimension}]`)) {\n console.log(`向量表维度变化,重建表 (${this.dimension}维)`);\n db.prepare('DROP TABLE IF EXISTS knowledge_vec').run();\n }\n } catch {}\n this.initTable(db);\n }\n\n upsert(db, vectorId, embedding) {\n if (!embedding) return false;\n try {\n db.prepare('DELETE FROM knowledge_vec WHERE id = ?').run(vectorId);\n db.prepare('INSERT INTO knowledge_vec (id, embedding) VALUES (?, ?)').run(\n vectorId,\n Buffer.from(embedding.buffer)\n );\n return true;\n } catch (e) {\n return false;\n }\n }\n\n search(db, queryVector, topK = 5) {\n ensureVec(db);\n if (!queryVector) return [];\n try {\n const results = db.prepare(`\n SELECT id, distance FROM knowledge_vec WHERE embedding MATCH ? ORDER BY distance LIMIT ?\n `).all(Buffer.from(queryVector.buffer), topK);\n return results.map(r => ({ vectorId: r.id, score: 1 - r.distance }));\n } catch (e) {\n return [];\n }\n }\n}\n\n// 全局实例\nlet bm25Index = null;\nconst vectorIndex = new VectorIndex();\n\n// ===== 文档拆分 (Chunking) =====\nconst CHUNK_MAX_CHARS = 400;\nconst CHUNK_OVERLAP = 50;\n\n/**\n * 将长文本按段落拆分成小块\n */\nfunction chunkText(text, maxChars = CHUNK_MAX_CHARS) {\n if (!text || text.length <= maxChars) return [text];\n\n const chunks = [];\n const paragraphs = text.split(/\\n\\s*\\n/).filter(p => p.trim());\n\n let current = '';\n for (const para of paragraphs) {\n if (current.length + para.length + 1 <= maxChars) {\n current = current ? current + '\\n\\n' + para : para;\n } else {\n if (current) chunks.push(current);\n if (para.length > maxChars) {\n const sentences = para.split(/(?<=[。!?.!?\\n])/).filter(s => s.trim());\n let sentBuf = '';\n for (const sent of sentences) {\n if (sentBuf.length + sent.length <= maxChars) {\n sentBuf += sent;\n } else {\n if (sentBuf) chunks.push(sentBuf);\n sentBuf = sent;\n }\n }\n if (sentBuf) current = sentBuf;\n else current = '';\n } else {\n current = para;\n }\n }\n }\n if (current) chunks.push(current);\n\n if (CHUNK_OVERLAP > 0 && chunks.length > 1) {\n const overlapped = [chunks[0]];\n for (let i = 1; i < chunks.length; i++) {\n const prevTail = chunks[i - 1].slice(-CHUNK_OVERLAP);\n overlapped.push(prevTail + chunks[i]);\n }\n return overlapped;\n }\n\n return chunks;\n}\n\n/**\n * 将知识条目拆分成可索引的块\n * 使用字符串复合 ID: \"{source}:{parentId}:{chunkIndex}\" 避免命名空间冲突\n */\nfunction chunkItem(item) {\n const fullText = item.content || '';\n const ns = NS[item.source] || 'k';\n\n if (fullText.length <= CHUNK_MAX_CHARS) {\n return [{\n chunkId: `${ns}:${item.id}:0`,\n source: item.source,\n parentId: item.id,\n chunkIndex: 0,\n title: item.title,\n content: item.title + ' ' + fullText,\n category: item.category,\n }];\n }\n\n const chunks = chunkText(fullText);\n return chunks.map((chunk, i) => ({\n chunkId: `${ns}:${item.id}:${i}`,\n source: item.source,\n parentId: item.id,\n chunkIndex: i,\n title: item.title + (chunks.length > 1 ? ` (第${i + 1}/${chunks.length}部分)` : ''),\n content: item.title + ' ' + chunk,\n category: item.category,\n }));\n}\n\n// 块内容缓存(chunkId → {title, content, category, source, parentId})\nlet chunkContentCache = {};\n\n/**\n * 重建索引(异步)\n * @param {boolean} forceVectors - 强制重建向量\n * @returns {Promise<number>} 索引条目数\n */\nasync function rebuildIndex(forceVectors = false) {\n const db = getDb();\n\n // 1. ai_knowledge 表\n const knowledgeItems = db.prepare(\n \"SELECT id, title, content, category FROM ai_knowledge WHERE status = 'active'\"\n ).all().map(item => ({ ...item, source: 'knowledge' }));\n\n // 2. tutorials 表(已发布教程)\n const tutorialItems = db.prepare(\n \"SELECT id, title, content, category FROM tutorials WHERE status = 'published'\"\n ).all().map(item => ({ ...item, source: 'tutorial' }));\n\n // 3. faqs 表(激活状态)\n const faqItems = db.prepare(\n \"SELECT id, question as title, answer as content, category FROM faqs WHERE status = 'active'\"\n ).all().map(item => ({ ...item, source: 'faq' }));\n\n // 合并所有数据源\n const rawItems = [...knowledgeItems, ...tutorialItems, ...faqItems];\n\n // 拆分长文档\n const allItems = [];\n let chunkCount = 0;\n for (const item of rawItems) {\n const chunks = chunkItem(item);\n allItems.push(...chunks);\n if (chunks.length > 1) chunkCount++;\n }\n if (chunkCount > 0) {\n console.log(`文档拆分: ${chunkCount} 篇长文档被拆分成 ${allItems.length} 个块`);\n }\n\n // 更新块内容缓存\n chunkContentCache = {};\n for (const item of allItems) {\n chunkContentCache[item.chunkId] = {\n title: item.title,\n content: item.content,\n category: item.category,\n source: item.source,\n parentId: item.parentId,\n };\n }\n\n // 重建 BM25\n bm25Index = new BM25Index();\n bm25Index.build(allItems.map(item => ({ ...item, id: item.chunkId })));\n\n // 重建向量(使用真实 Embedding API)\n try {\n vectorIndex.rebuildTable(db);\n\n const currentVecCount = db.prepare('SELECT COUNT(*) as cnt FROM knowledge_vec').get().cnt;\n if (!forceVectors && currentVecCount >= allItems.length) {\n // 跳过向量重建,但仍需恢复内存映射(重启后 chunkMeta 为空)\n vectorIdCounter = 0;\n chunkMeta = {};\n chunkIdToVectorId = {};\n for (let i = 0; i < allItems.length; i++) {\n const vecId = ++vectorIdCounter;\n chunkMeta[vecId] = {\n source: allItems[i].source,\n parentId: allItems[i].parentId,\n chunkIndex: allItems[i].chunkIndex,\n chunkId: allItems[i].chunkId,\n };\n chunkIdToVectorId[allItems[i].chunkId] = vecId;\n }\n console.log(`向量索引已存在 (${currentVecCount} 条),恢复内存映射`);\n } else {\n try { db.prepare('DELETE FROM knowledge_vec').run(); } catch {}\n\n // 重置映射\n vectorIdCounter = 0;\n chunkMeta = {};\n chunkIdToVectorId = {};\n\n const texts = allItems.map(item => item.title + ' ' + item.content);\n const embedConfig = getEmbeddingConfig();\n\n if (embedConfig.apiKey) {\n console.log(`正在调用 ${embedConfig.provider} Embedding API,共 ${texts.length} 条...`);\n const embeddings = await callEmbeddingBatch(texts, embedConfig);\n\n let successCount = 0;\n for (let i = 0; i < allItems.length; i++) {\n const vecId = ++vectorIdCounter;\n if (embeddings[i] && vectorIndex.upsert(db, vecId, embeddings[i])) {\n chunkMeta[vecId] = {\n source: allItems[i].source,\n parentId: allItems[i].parentId,\n chunkIndex: allItems[i].chunkIndex,\n chunkId: allItems[i].chunkId,\n };\n chunkIdToVectorId[allItems[i].chunkId] = vecId;\n successCount++;\n }\n }\n console.log(`向量索引已建立: ${successCount}/${allItems.length} 条成功`);\n } else {\n console.warn('未配置 AI API Key,跳过向量索引构建(仅使用 BM25)');\n }\n }\n } catch (e) {\n console.warn('向量索引初始化失败(不影响 BM25 检索):', e.message);\n }\n\n console.log(`RAG 索引已重建: ${allItems.length} 条 (知识库${knowledgeItems.length} + 教程${tutorialItems.length} + FAQ${faqItems.length})`);\n return allItems.length;\n}\n\n/**\n * 混合检索:BM25 + 向量,加权合并\n * @param {string} query - 用户问题\n * @param {number} topK - 返回条数\n * @returns {Promise<Array>} [{chunkId, title, content, score, source, parentId}]\n */\nasync function retrieve(query, topK = 5) {\n const db = getDb();\n\n if (!bm25Index) await rebuildIndex();\n\n // BM25 检索\n const bm25Results = bm25Index.search(query, topK * 2);\n\n // 向量检索\n let vecResults = [];\n try {\n const embedConfig = getEmbeddingConfig();\n if (embedConfig.apiKey) {\n const queryVector = await callEmbeddingAPI(query, embedConfig);\n if (queryVector) {\n const rawVecResults = vectorIndex.search(db, queryVector, topK * 2);\n // 将 vectorId 映射回 chunkId\n for (const r of rawVecResults) {\n const meta = chunkMeta[r.vectorId];\n if (meta) {\n vecResults.push({ id: meta.chunkId, score: r.score });\n }\n }\n }\n }\n } catch {}\n\n // 合并分数(加权:BM25 0.6 + 向量 0.4)\n const scoreMap = {};\n for (const r of bm25Results) {\n scoreMap[r.id] = (scoreMap[r.id] || 0) + r.score * 0.6;\n }\n for (const r of vecResults) {\n scoreMap[r.id] = (scoreMap[r.id] || 0) + r.score * 0.4;\n }\n\n const sorted = Object.entries(scoreMap)\n .map(([id, score]) => ({ chunkId: id, score }))\n .sort((a, b) => b.score - a.score)\n .slice(0, topK);\n\n if (sorted.length === 0) return [];\n\n // 获取详情\n const itemMap = {};\n const uncachedItems = [];\n\n for (const s of sorted) {\n if (chunkContentCache[s.chunkId]) {\n itemMap[s.chunkId] = chunkContentCache[s.chunkId];\n } else {\n uncachedItems.push(s);\n }\n }\n\n // 缓存未命中的,按来源查数据库\n const knowledgeParents = new Set();\n const tutorialParents = new Set();\n const faqParents = new Set();\n\n for (const s of uncachedItems) {\n const [source, parentId] = s.chunkId.split(':');\n const pid = Number(parentId);\n if (source === 'k') knowledgeParents.add(pid);\n else if (source === 't') tutorialParents.add(pid);\n else if (source === 'f') faqParents.add(pid);\n }\n\n if (knowledgeParents.size > 0) {\n const ids = [...knowledgeParents];\n const ph = ids.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, title, content, category FROM ai_knowledge WHERE id IN (${ph}) AND status = 'active'`).all(...ids);\n for (const r of rows) {\n itemMap[`k:${r.id}:0`] = { ...r, source: 'knowledge', parentId: r.id };\n }\n }\n if (tutorialParents.size > 0) {\n const ids = [...tutorialParents];\n const ph = ids.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, title, content, category FROM tutorials WHERE id IN (${ph}) AND status = 'published'`).all(...ids);\n for (const r of rows) {\n itemMap[`t:${r.id}:0`] = { ...r, source: 'tutorial', parentId: r.id };\n }\n }\n if (faqParents.size > 0) {\n const ids = [...faqParents];\n const ph = ids.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, question as title, answer as content, category FROM faqs WHERE id IN (${ph}) AND status = 'active'`).all(...ids);\n for (const r of rows) {\n itemMap[`f:${r.id}:0`] = { ...r, source: 'faq', parentId: r.id };\n }\n }\n\n return sorted\n .filter(s => itemMap[s.chunkId])\n .map(s => {\n const item = itemMap[s.chunkId];\n const images = [];\n const content = item.content || '';\n const mdImgs = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^)]+)\\)/g) || [];\n for (const m of mdImgs) {\n const url = m.match(/\\((https?:\\/\\/[^)]+)\\)/);\n if (url) images.push(url[1]);\n }\n const htmlImgs = content.match(/<img[^>]+src=[\"'](https?:\\/\\/[^\"']+)[\"'][^>]*>/gi) || [];\n for (const m of htmlImgs) {\n const url = m.match(/src=[\"'](https?:\\/\\/[^\"']+)[\"']/i);\n if (url) images.push(url[1]);\n }\n return {\n title: item.title,\n content: item.content,\n category: item.category,\n source: item.source,\n parentId: item.parentId,\n score: s.score,\n images: [...new Set(images)],\n };\n });\n\n // 异步更新知识库命中次数(不阻塞返回)\n try {\n const knowledgeIds = results.filter(r => r.source === 'knowledge').map(r => r.parentId);\n if (knowledgeIds.length > 0) {\n const placeholders = knowledgeIds.map(() => '?').join(',');\n db.prepare(`UPDATE ai_knowledge SET hit_count = hit_count + 1 WHERE id IN (${placeholders})`).run(...knowledgeIds);\n }\n } catch {}\n\n return results;\n}\n\n/**\n * 计算两个文本的相似度\n */\nfunction similarity(textA, textB) {\n const tokensA = new Set(tokenize(textA));\n const tokensB = new Set(tokenize(textB));\n if (tokensA.size === 0 || tokensB.size === 0) return 0;\n let overlap = 0;\n for (const t of tokensA) {\n if (tokensB.has(t)) overlap++;\n }\n const union = new Set([...tokensA, ...tokensB]).size;\n return union > 0 ? overlap / union : 0;\n}\n\n/**\n * 查找与给定问题相似的已有自动学习条目\n */\nasync function findSimilarLearned(question, threshold = 2.0) {\n const db = getDb();\n\n if (!bm25Index) await rebuildIndex();\n\n const bm25Results = bm25Index.search(question, 10);\n\n for (const result of bm25Results) {\n if (result.score < threshold) break;\n\n // chunkId 格式: \"k:{parentId}:{chunkIndex}\"\n const parts = result.id.split(':');\n const parentId = Number(parts[1]);\n\n const item = db.prepare(\n \"SELECT id, title, content FROM ai_knowledge WHERE id = ? AND category = '自动学习' AND status = 'active'\"\n ).get(parentId);\n\n if (item) {\n return { ...item, score: result.score };\n }\n }\n\n return null;\n}\n\n/**\n * 清理自动学习条目\n */\nfunction cleanupLearned(maxCount = 200) {\n const db = getDb();\n const items = db.prepare(\n \"SELECT id, title, content FROM ai_knowledge WHERE category = '自动学习' AND status = 'active' ORDER BY updated_at DESC\"\n ).all();\n\n if (items.length === 0) return { merged: 0, removed: 0 };\n\n let merged = 0;\n const toDelete = new Set();\n\n for (let i = 0; i < items.length; i++) {\n if (toDelete.has(items[i].id)) continue;\n for (let j = i + 1; j < items.length; j++) {\n if (toDelete.has(items[j].id)) continue;\n const score = similarity(items[i].title, items[j].title);\n if (score >= 0.4) {\n const existingContent = items[i].content;\n const newAnswer = items[j].content.replace(/^用户问题:.*?\\n\\n参考回答:/s, '');\n if (newAnswer && !existingContent.includes(newAnswer.slice(0, 50))) {\n db.prepare('UPDATE ai_knowledge SET content = content || ? WHERE id = ?')\n .run('\\n\\n补充回答:' + newAnswer, items[i].id);\n }\n toDelete.add(items[j].id);\n merged++;\n }\n }\n }\n\n if (toDelete.size > 0) {\n const placeholders = Array.from(toDelete).map(() => '?').join(',');\n db.prepare(`DELETE FROM ai_knowledge WHERE id IN (${placeholders})`).run(...toDelete);\n }\n\n let removed = 0;\n const remaining = db.prepare(\n \"SELECT COUNT(*) as cnt FROM ai_knowledge WHERE category = '自动学习' AND status = 'active'\"\n ).get().cnt;\n\n if (remaining > maxCount) {\n const excess = remaining - maxCount;\n const oldItems = db.prepare(\n \"SELECT id FROM ai_knowledge WHERE category = '自动学习' AND status = 'active' ORDER BY updated_at ASC LIMIT ?\"\n ).all(excess);\n if (oldItems.length > 0) {\n const placeholders = oldItems.map(() => '?').join(',');\n db.prepare(`DELETE FROM ai_knowledge WHERE id IN (${placeholders})`).run(...oldItems.map(i => i.id));\n removed = oldItems.length;\n }\n }\n\n return { merged, removed };\n}\n\nmodule.exports = { retrieve, rebuildIndex, tokenize, similarity, findSimilarLearned, cleanupLearned };\n"}, "74397767c20a14462b73dc91cf277dc8516f92a5": {"t": 1785766727.2267144, "n": 22723, "h": "efc9d29dc0c99ec2", "c": "/**\n * RAG 检索服务 — 混合检索(BM25 + 向量)+ Re-ranking\n * 向量检索使用真实 Embedding API(DeepSeek / OpenAI 兼容)\n */\nconst { getDb } = require('../database/schema');\n\nlet sqliteVecLoaded = false;\nfunction ensureVec(db) {\n if (sqliteVecLoaded) return;\n try {\n const sqliteVec = require('sqlite-vec');\n sqliteVec.load(db);\n sqliteVecLoaded = true;\n } catch (e) {\n console.warn('sqlite-vec 加载失败:', e.message);\n }\n}\n\n// Embedding 配置\nconst EMBEDDING_DIMENSION = 512; // bge-small-zh-v1.5 输出维度\nconst EMBEDDING_BATCH_SIZE = 10; // DashScope 限制每批最多 10 条\n\n// 命名空间前缀(用于 chunk ID,避免冲突)\nconst NS = { knowledge: 'k', tutorial: 't', faq: 'f' };\n\n// Embedding 模型映射\nconst EMBEDDING_MODELS = {\n deepseek: 'deepseek-embedding',\n openai: 'text-embedding-3-small',\n qwen: 'text-embedding-v3',\n};\n\n// Provider 对应的 Embedding API URL\nfunction getEmbeddingUrl(provider) {\n const urls = {\n deepseek: 'https://api.deepseek.com/embeddings',\n openai: 'https://api.openai.com/v1/embeddings',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings',\n };\n return urls[provider] || '';\n}\n\n// BM25 参数\nconst BM25_K1 = 1.5;\nconst BM25_B = 0.75;\n\n// 同义词映射(用于查询扩展)\nconst SYNONYMS = {\n '工单': ['工单', '工单', '问题', '反馈', '需求'],\n '教程': ['教程', '课程', '学习', '视频'],\n '客服': ['客服', '人工', '帮助', '支持'],\n};\n\n// 中文停用词\nconst STOP_WORDS = new Set([\n '的', '了', '是', '在', '我', '有', '和', '就', '不', '人', '都', '一',\n '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有',\n '看', '好', '自己', '这', '他', '么', '把', '那', '你', '它', '她', '哪',\n '如何', '怎么', '什么', '为什么', '可以', '能', '可以', '请', '求', '帮',\n]);\n\n/**\n * 分词(简单实现:中文逐字 + 英文按空格)\n */\nfunction tokenize(text) {\n if (!text) return [];\n const tokens = [];\n // 匹配中文字符或英文单词\n const matches = text.match(/[\\u4e00-\\u9fff]|[a-zA-Z0-9]+/g) || [];\n for (const m of matches) {\n const lower = m.toLowerCase();\n if (!STOP_WORDS.has(lower) && lower.length > 0) {\n tokens.push(lower);\n }\n }\n return tokens;\n}\n\n/**\n * 查询扩展:添加同义词\n */\nfunction expandQuery(query) {\n let expanded = query;\n for (const [key, synonyms] of Object.entries(SYNONYMS)) {\n if (query.includes(key)) {\n expanded += ' ' + synonyms.join(' ');\n }\n }\n return expanded;\n}\n\n// ===== BM25 索引 =====\nclass BM25Index {\n constructor() {\n this.docs = [];\n this.docCount = 0;\n this.avgDocLen = 0;\n this.docFreq = {}; // term -> 文档频率\n this.docLens = [];\n this.invertedIndex = {}; // term -> [{docIdx, tf}]\n }\n\n build(items) {\n this.docs = items;\n this.docCount = items.length;\n this.docFreq = {};\n this.invertedIndex = {};\n this.docLens = [];\n\n let totalLen = 0;\n for (let i = 0; i < items.length; i++) {\n const tokens = tokenize(items[i].title + ' ' + items[i].content);\n this.docLens.push(tokens.length);\n totalLen += tokens.length;\n\n // 统计词频\n const tf = {};\n for (const t of tokens) tf[t] = (tf[t] || 0) + 1;\n\n // 构建倒排索引\n for (const [term, freq] of Object.entries(tf)) {\n if (!this.invertedIndex[term]) this.invertedIndex[term] = [];\n this.invertedIndex[term].push({ docIdx: i, tf: freq });\n this.docFreq[term] = (this.docFreq[term] || 0) + 1;\n }\n }\n this.avgDocLen = totalLen / this.docCount || 1;\n }\n\n search(query, topK = 5) {\n const expandedQuery = expandQuery(query);\n const queryTokens = tokenize(expandedQuery);\n if (queryTokens.length === 0) return [];\n\n const scores = new Array(this.docCount).fill(0);\n\n for (const term of queryTokens) {\n const postings = this.invertedIndex[term] || [];\n const df = this.docFreq[term] || 0;\n if (df === 0) continue;\n\n // IDF 部分\n const idf = Math.log((this.docCount - df + 0.5) / (df + 0.5) + 1);\n\n for (const { docIdx, tf } of postings) {\n const docLen = this.docLens[docIdx];\n // BM25 公式\n const tfNorm = (tf * (BM25_K1 + 1)) / (tf + BM25_K1 * (1 - BM25_B + BM25_B * docLen / this.avgDocLen));\n scores[docIdx] += idf * tfNorm;\n }\n }\n\n // 排序取 Top-K\n const results = [];\n for (let i = 0; i < this.docCount; i++) {\n if (scores[i] > 0) {\n results.push({ id: this.docs[i].id, score: scores[i] });\n }\n }\n results.sort((a, b) => b.score - a.score);\n return results.slice(0, topK);\n }\n}\n\n/**\n * 调用 Embedding API(异步,使用 fetch)\n * @param {string} text - 要嵌入的文本\n * @param {object} config - {provider, apiKey, baseUrl, model}\n * @returns {Promise<Float32Array|null>} 嵌入向量\n */\nasync function callEmbeddingAPI(text, config) {\n const url = config.baseUrl || getEmbeddingUrl(config.provider);\n if (!url || !config.apiKey) return null;\n\n const model = config.model || EMBEDDING_MODELS[config.provider] || 'deepseek-embedding';\n\n try {\n const resp = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${config.apiKey}`,\n },\n body: JSON.stringify({\n model,\n input: text.slice(0, 8000),\n }),\n signal: AbortSignal.timeout(30000),\n });\n\n if (!resp.ok) {\n console.warn('Embedding API HTTP 错误:', resp.status);\n return null;\n }\n\n const json = await resp.json();\n if (json.data && json.data[0] && json.data[0].embedding) {\n return new Float32Array(json.data[0].embedding);\n }\n if (json.error) {\n console.warn('Embedding API 错误:', json.error.message || json.error);\n }\n return null;\n } catch (e) {\n console.warn('Embedding API 调用失败:', e.message);\n return null;\n }\n}\n\n/**\n * 批量调用 Embedding API(异步)\n * @param {string[]} texts - 文本数组\n * @param {object} config - API 配置\n * @returns {Promise<Float32Array[]>} 嵌入向量数组(失败的用 null 占位)\n */\nasync function callEmbeddingBatch(texts, config) {\n const url = config.baseUrl || getEmbeddingUrl(config.provider);\n if (!url || !config.apiKey) return texts.map(() => null);\n\n const model = config.model || EMBEDDING_MODELS[config.provider] || 'deepseek-embedding';\n const results = new Array(texts.length).fill(null);\n\n // 分批处理\n for (let i = 0; i < texts.length; i += EMBEDDING_BATCH_SIZE) {\n const batch = texts.slice(i, i + EMBEDDING_BATCH_SIZE);\n\n try {\n const resp = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${config.apiKey}`,\n },\n body: JSON.stringify({\n model,\n input: batch.map(t => t.slice(0, 8000)),\n }),\n signal: AbortSignal.timeout(60000),\n });\n\n if (!resp.ok) {\n console.warn('Embedding 批量 API HTTP 错误:', resp.status);\n continue;\n }\n\n const json = await resp.json();\n if (json.data) {\n for (const item of json.data) {\n results[i + item.index] = new Float32Array(item.embedding);\n }\n }\n if (json.error) {\n console.warn('Embedding 批量 API 错误:', json.error.message || json.error);\n }\n } catch (e) {\n console.warn('Embedding 批量 API 调用失败:', e.message);\n }\n\n // 批次间隔,避免限流\n if (i + EMBEDDING_BATCH_SIZE < texts.length) {\n await new Promise(r => setTimeout(r, 1000));\n }\n }\n\n return results;\n}\n\n/**\n * 从 settings 读取 Embedding 配置\n * 支持独立的 embedding 配置,回退到通用 ai 配置\n */\nfunction getEmbeddingConfig() {\n const db = getDb();\n const rows = db.prepare(\n \"SELECT key, value FROM settings WHERE key IN ('embedding_provider','embedding_api_key','embedding_base_url','embedding_model','ai_provider','ai_api_key','ai_base_url')\"\n ).all();\n const config = {};\n for (const row of rows) config[row.key] = row.value;\n return {\n provider: config.embedding_provider || config.ai_provider || 'deepseek',\n apiKey: config.embedding_api_key || config.ai_api_key || '',\n baseUrl: config.embedding_base_url || '',\n model: config.embedding_model || '',\n };\n}\n\n// ===== 向量索引(使用 sqlite-vec)=====\n// 向量表用自增整数 ID,chunkMeta 映射 vectorId → {source, parentId, chunkIndex}\nlet vectorIdCounter = 0;\nlet chunkMeta = {}; // vectorId → {source, parentId, chunkIndex, chunkId}\nlet chunkIdToVectorId = {}; // chunkId → vectorId\n\nclass VectorIndex {\n constructor() {\n this.dimension = EMBEDDING_DIMENSION;\n }\n\n initTable(db) {\n ensureVec(db);\n db.exec(`\n CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_vec USING vec0(\n id INTEGER PRIMARY KEY,\n embedding float[${this.dimension}]\n );\n `);\n }\n\n rebuildTable(db) {\n ensureVec(db);\n try {\n const info = db.prepare(\"SELECT sql FROM sqlite_master WHERE name = 'knowledge_vec'\").get();\n if (info && !info.sql.includes(`float[${this.dimension}]`)) {\n console.log(`向量表维度变化,重建表 (${this.dimension}维)`);\n db.prepare('DROP TABLE IF EXISTS knowledge_vec').run();\n }\n } catch {}\n this.initTable(db);\n }\n\n upsert(db, vectorId, embedding) {\n if (!embedding) return false;\n try {\n db.prepare('DELETE FROM knowledge_vec WHERE id = ?').run(vectorId);\n db.prepare('INSERT INTO knowledge_vec (id, embedding) VALUES (?, ?)').run(\n vectorId,\n Buffer.from(embedding.buffer)\n );\n return true;\n } catch (e) {\n return false;\n }\n }\n\n search(db, queryVector, topK = 5) {\n ensureVec(db);\n if (!queryVector) return [];\n try {\n const results = db.prepare(`\n SELECT id, distance FROM knowledge_vec WHERE embedding MATCH ? ORDER BY distance LIMIT ?\n `).all(Buffer.from(queryVector.buffer), topK);\n return results.map(r => ({ vectorId: r.id, score: 1 - r.distance }));\n } catch (e) {\n return [];\n }\n }\n}\n\n// 全局实例\nlet bm25Index = null;\nconst vectorIndex = new VectorIndex();\n\n// ===== 文档拆分 (Chunking) =====\nconst CHUNK_MAX_CHARS = 400;\nconst CHUNK_OVERLAP = 50;\n\n/**\n * 将长文本按段落拆分成小块\n */\nfunction chunkText(text, maxChars = CHUNK_MAX_CHARS) {\n if (!text || text.length <= maxChars) return [text];\n\n const chunks = [];\n const paragraphs = text.split(/\\n\\s*\\n/).filter(p => p.trim());\n\n let current = '';\n for (const para of paragraphs) {\n if (current.length + para.length + 1 <= maxChars) {\n current = current ? current + '\\n\\n' + para : para;\n } else {\n if (current) chunks.push(current);\n if (para.length > maxChars) {\n const sentences = para.split(/(?<=[。!?.!?\\n])/).filter(s => s.trim());\n let sentBuf = '';\n for (const sent of sentences) {\n if (sentBuf.length + sent.length <= maxChars) {\n sentBuf += sent;\n } else {\n if (sentBuf) chunks.push(sentBuf);\n sentBuf = sent;\n }\n }\n if (sentBuf) current = sentBuf;\n else current = '';\n } else {\n current = para;\n }\n }\n }\n if (current) chunks.push(current);\n\n if (CHUNK_OVERLAP > 0 && chunks.length > 1) {\n const overlapped = [chunks[0]];\n for (let i = 1; i < chunks.length; i++) {\n const prevTail = chunks[i - 1].slice(-CHUNK_OVERLAP);\n overlapped.push(prevTail + chunks[i]);\n }\n return overlapped;\n }\n\n return chunks;\n}\n\n/**\n * 将知识条目拆分成可索引的块\n * 使用字符串复合 ID: \"{source}:{parentId}:{chunkIndex}\" 避免命名空间冲突\n */\nfunction chunkItem(item) {\n const fullText = item.content || '';\n const ns = NS[item.source] || 'k';\n\n if (fullText.length <= CHUNK_MAX_CHARS) {\n return [{\n chunkId: `${ns}:${item.id}:0`,\n source: item.source,\n parentId: item.id,\n chunkIndex: 0,\n title: item.title,\n content: item.title + ' ' + fullText,\n category: item.category,\n }];\n }\n\n const chunks = chunkText(fullText);\n return chunks.map((chunk, i) => ({\n chunkId: `${ns}:${item.id}:${i}`,\n source: item.source,\n parentId: item.id,\n chunkIndex: i,\n title: item.title + (chunks.length > 1 ? ` (第${i + 1}/${chunks.length}部分)` : ''),\n content: item.title + ' ' + chunk,\n category: item.category,\n }));\n}\n\n// 块内容缓存(chunkId → {title, content, category, source, parentId})\nlet chunkContentCache = {};\n\n/**\n * 重建索引(异步)\n * @param {boolean} forceVectors - 强制重建向量\n * @returns {Promise<number>} 索引条目数\n */\nasync function rebuildIndex(forceVectors = false) {\n const db = getDb();\n\n // 1. ai_knowledge 表\n const knowledgeItems = db.prepare(\n \"SELECT id, title, content, category FROM ai_knowledge WHERE status = 'active'\"\n ).all().map(item => ({ ...item, source: 'knowledge' }));\n\n // 2. tutorials 表(已发布教程)\n const tutorialItems = db.prepare(\n \"SELECT id, title, content, category FROM tutorials WHERE status = 'published'\"\n ).all().map(item => ({ ...item, source: 'tutorial' }));\n\n // 3. faqs 表(激活状态)\n const faqItems = db.prepare(\n \"SELECT id, question as title, answer as content, category FROM faqs WHERE status = 'active'\"\n ).all().map(item => ({ ...item, source: 'faq' }));\n\n // 合并所有数据源\n const rawItems = [...knowledgeItems, ...tutorialItems, ...faqItems];\n\n // 拆分长文档\n const allItems = [];\n let chunkCount = 0;\n for (const item of rawItems) {\n const chunks = chunkItem(item);\n allItems.push(...chunks);\n if (chunks.length > 1) chunkCount++;\n }\n if (chunkCount > 0) {\n console.log(`文档拆分: ${chunkCount} 篇长文档被拆分成 ${allItems.length} 个块`);\n }\n\n // 更新块内容缓存\n chunkContentCache = {};\n for (const item of allItems) {\n chunkContentCache[item.chunkId] = {\n title: item.title,\n content: item.content,\n category: item.category,\n source: item.source,\n parentId: item.parentId,\n };\n }\n\n // 重建 BM25\n bm25Index = new BM25Index();\n bm25Index.build(allItems.map(item => ({ ...item, id: item.chunkId })));\n\n // 重建向量(使用真实 Embedding API)\n try {\n vectorIndex.rebuildTable(db);\n\n const currentVecCount = db.prepare('SELECT COUNT(*) as cnt FROM knowledge_vec').get().cnt;\n if (!forceVectors && currentVecCount >= allItems.length) {\n // 跳过向量重建,但仍需恢复内存映射(重启后 chunkMeta 为空)\n vectorIdCounter = 0;\n chunkMeta = {};\n chunkIdToVectorId = {};\n for (let i = 0; i < allItems.length; i++) {\n const vecId = ++vectorIdCounter;\n chunkMeta[vecId] = {\n source: allItems[i].source,\n parentId: allItems[i].parentId,\n chunkIndex: allItems[i].chunkIndex,\n chunkId: allItems[i].chunkId,\n };\n chunkIdToVectorId[allItems[i].chunkId] = vecId;\n }\n console.log(`向量索引已存在 (${currentVecCount} 条),恢复内存映射`);\n } else {\n try { db.prepare('DELETE FROM knowledge_vec').run(); } catch {}\n\n // 重置映射\n vectorIdCounter = 0;\n chunkMeta = {};\n chunkIdToVectorId = {};\n\n const texts = allItems.map(item => item.title + ' ' + item.content);\n const embedConfig = getEmbeddingConfig();\n\n if (embedConfig.apiKey) {\n console.log(`正在调用 ${embedConfig.provider} Embedding API,共 ${texts.length} 条...`);\n const embeddings = await callEmbeddingBatch(texts, embedConfig);\n\n let successCount = 0;\n for (let i = 0; i < allItems.length; i++) {\n const vecId = ++vectorIdCounter;\n if (embeddings[i] && vectorIndex.upsert(db, vecId, embeddings[i])) {\n chunkMeta[vecId] = {\n source: allItems[i].source,\n parentId: allItems[i].parentId,\n chunkIndex: allItems[i].chunkIndex,\n chunkId: allItems[i].chunkId,\n };\n chunkIdToVectorId[allItems[i].chunkId] = vecId;\n successCount++;\n }\n }\n console.log(`向量索引已建立: ${successCount}/${allItems.length} 条成功`);\n } else {\n console.warn('未配置 AI API Key,跳过向量索引构建(仅使用 BM25)');\n }\n }\n } catch (e) {\n console.warn('向量索引初始化失败(不影响 BM25 检索):', e.message);\n }\n\n console.log(`RAG 索引已重建: ${allItems.length} 条 (知识库${knowledgeItems.length} + 教程${tutorialItems.length} + FAQ${faqItems.length})`);\n return allItems.length;\n}\n\n/**\n * 混合检索:BM25 + 向量,加权合并\n * @param {string} query - 用户问题\n * @param {number} topK - 返回条数\n * @returns {Promise<Array>} [{chunkId, title, content, score, source, parentId}]\n */\nasync function retrieve(query, topK = 5) {\n const db = getDb();\n\n if (!bm25Index) await rebuildIndex();\n\n // BM25 检索\n const bm25Results = bm25Index.search(query, topK * 2);\n\n // 向量检索\n let vecResults = [];\n try {\n const embedConfig = getEmbeddingConfig();\n if (embedConfig.apiKey) {\n const queryVector = await callEmbeddingAPI(query, embedConfig);\n if (queryVector) {\n const rawVecResults = vectorIndex.search(db, queryVector, topK * 2);\n // 将 vectorId 映射回 chunkId\n for (const r of rawVecResults) {\n const meta = chunkMeta[r.vectorId];\n if (meta) {\n vecResults.push({ id: meta.chunkId, score: r.score });\n }\n }\n }\n }\n } catch {}\n\n // 合并分数(加权:BM25 0.6 + 向量 0.4)\n const scoreMap = {};\n for (const r of bm25Results) {\n scoreMap[r.id] = (scoreMap[r.id] || 0) + r.score * 0.6;\n }\n for (const r of vecResults) {\n scoreMap[r.id] = (scoreMap[r.id] || 0) + r.score * 0.4;\n }\n\n const sorted = Object.entries(scoreMap)\n .map(([id, score]) => ({ chunkId: id, score }))\n .sort((a, b) => b.score - a.score)\n .slice(0, topK);\n\n if (sorted.length === 0) return [];\n\n // 获取详情\n const itemMap = {};\n const uncachedItems = [];\n\n for (const s of sorted) {\n if (chunkContentCache[s.chunkId]) {\n itemMap[s.chunkId] = chunkContentCache[s.chunkId];\n } else {\n uncachedItems.push(s);\n }\n }\n\n // 缓存未命中的,按来源查数据库\n const knowledgeParents = new Set();\n const tutorialParents = new Set();\n const faqParents = new Set();\n\n for (const s of uncachedItems) {\n const [source, parentId] = s.chunkId.split(':');\n const pid = Number(parentId);\n if (source === 'k') knowledgeParents.add(pid);\n else if (source === 't') tutorialParents.add(pid);\n else if (source === 'f') faqParents.add(pid);\n }\n\n if (knowledgeParents.size > 0) {\n const ids = [...knowledgeParents];\n const ph = ids.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, title, content, category FROM ai_knowledge WHERE id IN (${ph}) AND status = 'active'`).all(...ids);\n for (const r of rows) {\n itemMap[`k:${r.id}:0`] = { ...r, source: 'knowledge', parentId: r.id };\n }\n }\n if (tutorialParents.size > 0) {\n const ids = [...tutorialParents];\n const ph = ids.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, title, content, category FROM tutorials WHERE id IN (${ph}) AND status = 'published'`).all(...ids);\n for (const r of rows) {\n itemMap[`t:${r.id}:0`] = { ...r, source: 'tutorial', parentId: r.id };\n }\n }\n if (faqParents.size > 0) {\n const ids = [...faqParents];\n const ph = ids.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, question as title, answer as content, category FROM faqs WHERE id IN (${ph}) AND status = 'active'`).all(...ids);\n for (const r of rows) {\n itemMap[`f:${r.id}:0`] = { ...r, source: 'faq', parentId: r.id };\n }\n }\n\n return sorted\n .filter(s => itemMap[s.chunkId])\n .map(s => {\n const item = itemMap[s.chunkId];\n const images = [];\n const content = item.content || '';\n const mdImgs = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^)]+)\\)/g) || [];\n for (const m of mdImgs) {\n const url = m.match(/\\((https?:\\/\\/[^)]+)\\)/);\n if (url) images.push(url[1]);\n }\n const htmlImgs = content.match(/<img[^>]+src=[\"'](https?:\\/\\/[^\"']+)[\"'][^>]*>/gi) || [];\n for (const m of htmlImgs) {\n const url = m.match(/src=[\"'](https?:\\/\\/[^\"']+)[\"']/i);\n if (url) images.push(url[1]);\n }\n return {\n title: item.title,\n content: item.content,\n category: item.category,\n source: item.source,\n parentId: item.parentId,\n score: s.score,\n images: [...new Set(images)],\n };\n });\n}\n\n/**\n * 计算两个文本的相似度\n */\nfunction similarity(textA, textB) {\n const tokensA = new Set(tokenize(textA));\n const tokensB = new Set(tokenize(textB));\n if (tokensA.size === 0 || tokensB.size === 0) return 0;\n let overlap = 0;\n for (const t of tokensA) {\n if (tokensB.has(t)) overlap++;\n }\n const union = new Set([...tokensA, ...tokensB]).size;\n return union > 0 ? overlap / union : 0;\n}\n\n/**\n * 查找与给定问题相似的已有自动学习条目\n */\nasync function findSimilarLearned(question, threshold = 2.0) {\n const db = getDb();\n\n if (!bm25Index) await rebuildIndex();\n\n const bm25Results = bm25Index.search(question, 10);\n\n for (const result of bm25Results) {\n if (result.score < threshold) break;\n\n // chunkId 格式: \"k:{parentId}:{chunkIndex}\"\n const parts = result.id.split(':');\n const parentId = Number(parts[1]);\n\n const item = db.prepare(\n \"SELECT id, title, content FROM ai_knowledge WHERE id = ? AND category = '自动学习' AND status = 'active'\"\n ).get(parentId);\n\n if (item) {\n return { ...item, score: result.score };\n }\n }\n\n return null;\n}\n\n/**\n * 清理自动学习条目\n */\nfunction cleanupLearned(maxCount = 200) {\n const db = getDb();\n const items = db.prepare(\n \"SELECT id, title, content FROM ai_knowledge WHERE category = '自动学习' AND status = 'active' ORDER BY updated_at DESC\"\n ).all();\n\n if (items.length === 0) return { merged: 0, removed: 0 };\n\n let merged = 0;\n const toDelete = new Set();\n\n for (let i = 0; i < items.length; i++) {\n if (toDelete.has(items[i].id)) continue;\n for (let j = i + 1; j < items.length; j++) {\n if (toDelete.has(items[j].id)) continue;\n const score = similarity(items[i].title, items[j].title);\n if (score >= 0.4) {\n const existingContent = items[i].content;\n const newAnswer = items[j].content.replace(/^用户问题:.*?\\n\\n参考回答:/s, '');\n if (newAnswer && !existingContent.includes(newAnswer.slice(0, 50))) {\n db.prepare('UPDATE ai_knowledge SET content = content || ? WHERE id = ?')\n .run('\\n\\n补充回答:' + newAnswer, items[i].id);\n }\n toDelete.add(items[j].id);\n merged++;\n }\n }\n }\n\n if (toDelete.size > 0) {\n const placeholders = Array.from(toDelete).map(() => '?').join(',');\n db.prepare(`DELETE FROM ai_knowledge WHERE id IN (${placeholders})`).run(...toDelete);\n }\n\n let removed = 0;\n const remaining = db.prepare(\n \"SELECT COUNT(*) as cnt FROM ai_knowledge WHERE category = '自动学习' AND status = 'active'\"\n ).get().cnt;\n\n if (remaining > maxCount) {\n const excess = remaining - maxCount;\n const oldItems = db.prepare(\n \"SELECT id FROM ai_knowledge WHERE category = '自动学习' AND status = 'active' ORDER BY updated_at ASC LIMIT ?\"\n ).all(excess);\n if (oldItems.length > 0) {\n const placeholders = oldItems.map(() => '?').join(',');\n db.prepare(`DELETE FROM ai_knowledge WHERE id IN (${placeholders})`).run(...oldItems.map(i => i.id));\n removed = oldItems.length;\n }\n }\n\n return { merged, removed };\n}\n\nmodule.exports = { retrieve, rebuildIndex, tokenize, similarity, findSimilarLearned, cleanupLearned };\n"}, "07d9a4cb735c78690d56faa55797451246cb6559": {"t": 1785766728.5190501, "n": 22237, "h": "e1f7cc0223611228", "c": "/**\n * RAG 检索服务 — 混合检索(BM25 + 向量)+ Re-ranking\n * 向量检索使用真实 Embedding API(DeepSeek / OpenAI 兼容)\n */\nconst { getDb } = require('../database/schema');\n\nlet sqliteVecLoaded = false;\nfunction ensureVec(db) {\n if (sqliteVecLoaded) return;\n try {\n const sqliteVec = require('sqlite-vec');\n sqliteVec.load(db);\n sqliteVecLoaded = true;\n } catch (e) {\n console.warn('sqlite-vec 加载失败:', e.message);\n }\n}\n\n// Embedding 配置\nconst EMBEDDING_DIMENSION = 512; // bge-small-zh-v1.5 输出维度\nconst EMBEDDING_BATCH_SIZE = 10; // DashScope 限制每批最多 10 条\n\n// 命名空间前缀(用于 chunk ID,避免冲突)\nconst NS = { knowledge: 'k', tutorial: 't', faq: 'f' };\n\n// Embedding 模型映射\nconst EMBEDDING_MODELS = {\n deepseek: 'deepseek-embedding',\n openai: 'text-embedding-3-small',\n qwen: 'text-embedding-v3',\n};\n\n// Provider 对应的 Embedding API URL\nfunction getEmbeddingUrl(provider) {\n const urls = {\n deepseek: 'https://api.deepseek.com/embeddings',\n openai: 'https://api.openai.com/v1/embeddings',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings',\n };\n return urls[provider] || '';\n}\n\n// BM25 参数\nconst BM25_K1 = 1.5;\nconst BM25_B = 0.75;\n\n// 同义词映射(用于查询扩展)\nconst SYNONYMS = {\n '工单': ['工单', '工单', '问题', '反馈', '需求'],\n '教程': ['教程', '课程', '学习', '视频'],\n '客服': ['客服', '人工', '帮助', '支持'],\n};\n\n// 中文停用词\nconst STOP_WORDS = new Set([\n '的', '了', '是', '在', '我', '有', '和', '就', '不', '人', '都', '一',\n '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有',\n '看', '好', '自己', '这', '他', '么', '把', '那', '你', '它', '她', '哪',\n '如何', '怎么', '什么', '为什么', '可以', '能', '可以', '请', '求', '帮',\n]);\n\n/**\n * 分词(简单实现:中文逐字 + 英文按空格)\n */\nfunction tokenize(text) {\n if (!text) return [];\n const tokens = [];\n // 匹配中文字符或英文单词\n const matches = text.match(/[\\u4e00-\\u9fff]|[a-zA-Z0-9]+/g) || [];\n for (const m of matches) {\n const lower = m.toLowerCase();\n if (!STOP_WORDS.has(lower) && lower.length > 0) {\n tokens.push(lower);\n }\n }\n return tokens;\n}\n\n/**\n * 查询扩展:添加同义词\n */\nfunction expandQuery(query) {\n let expanded = query;\n for (const [key, synonyms] of Object.entries(SYNONYMS)) {\n if (query.includes(key)) {\n expanded += ' ' + synonyms.join(' ');\n }\n }\n return expanded;\n}\n\n// ===== BM25 索引 =====\nclass BM25Index {\n constructor() {\n this.docs = [];\n this.docCount = 0;\n this.avgDocLen = 0;\n this.docFreq = {}; // term -> 文档频率\n this.docLens = [];\n this.invertedIndex = {}; // term -> [{docIdx, tf}]\n }\n\n build(items) {\n this.docs = items;\n this.docCount = items.length;\n this.docFreq = {};\n this.invertedIndex = {};\n this.docLens = [];\n\n let totalLen = 0;\n for (let i = 0; i < items.length; i++) {\n const tokens = tokenize(items[i].title + ' ' + items[i].content);\n this.docLens.push(tokens.length);\n totalLen += tokens.length;\n\n // 统计词频\n const tf = {};\n for (const t of tokens) tf[t] = (tf[t] || 0) + 1;\n\n // 构建倒排索引\n for (const [term, freq] of Object.entries(tf)) {\n if (!this.invertedIndex[term]) this.invertedIndex[term] = [];\n this.invertedIndex[term].push({ docIdx: i, tf: freq });\n this.docFreq[term] = (this.docFreq[term] || 0) + 1;\n }\n }\n this.avgDocLen = totalLen / this.docCount || 1;\n }\n\n search(query, topK = 5) {\n const expandedQuery = expandQuery(query);\n const queryTokens = tokenize(expandedQuery);\n if (queryTokens.length === 0) return [];\n\n const scores = new Array(this.docCount).fill(0);\n\n for (const term of queryTokens) {\n const postings = this.invertedIndex[term] || [];\n const df = this.docFreq[term] || 0;\n if (df === 0) continue;\n\n // IDF 部分\n const idf = Math.log((this.docCount - df + 0.5) / (df + 0.5) + 1);\n\n for (const { docIdx, tf } of postings) {\n const docLen = this.docLens[docIdx];\n // BM25 公式\n const tfNorm = (tf * (BM25_K1 + 1)) / (tf + BM25_K1 * (1 - BM25_B + BM25_B * docLen / this.avgDocLen));\n scores[docIdx] += idf * tfNorm;\n }\n }\n\n // 排序取 Top-K\n const results = [];\n for (let i = 0; i < this.docCount; i++) {\n if (scores[i] > 0) {\n results.push({ id: this.docs[i].id, score: scores[i] });\n }\n }\n results.sort((a, b) => b.score - a.score);\n return results.slice(0, topK);\n }\n}\n\n/**\n * 调用 Embedding API(异步,使用 fetch)\n * @param {string} text - 要嵌入的文本\n * @param {object} config - {provider, apiKey, baseUrl, model}\n * @returns {Promise<Float32Array|null>} 嵌入向量\n */\nasync function callEmbeddingAPI(text, config) {\n const url = config.baseUrl || getEmbeddingUrl(config.provider);\n if (!url || !config.apiKey) return null;\n\n const model = config.model || EMBEDDING_MODELS[config.provider] || 'deepseek-embedding';\n\n try {\n const resp = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${config.apiKey}`,\n },\n body: JSON.stringify({\n model,\n input: text.slice(0, 8000),\n }),\n signal: AbortSignal.timeout(30000),\n });\n\n if (!resp.ok) {\n console.warn('Embedding API HTTP 错误:', resp.status);\n return null;\n }\n\n const json = await resp.json();\n if (json.data && json.data[0] && json.data[0].embedding) {\n return new Float32Array(json.data[0].embedding);\n }\n if (json.error) {\n console.warn('Embedding API 错误:', json.error.message || json.error);\n }\n return null;\n } catch (e) {\n console.warn('Embedding API 调用失败:', e.message);\n return null;\n }\n}\n\n/**\n * 批量调用 Embedding API(异步)\n * @param {string[]} texts - 文本数组\n * @param {object} config - API 配置\n * @returns {Promise<Float32Array[]>} 嵌入向量数组(失败的用 null 占位)\n */\nasync function callEmbeddingBatch(texts, config) {\n const url = config.baseUrl || getEmbeddingUrl(config.provider);\n if (!url || !config.apiKey) return texts.map(() => null);\n\n const model = config.model || EMBEDDING_MODELS[config.provider] || 'deepseek-embedding';\n const results = new Array(texts.length).fill(null);\n\n // 分批处理\n for (let i = 0; i < texts.length; i += EMBEDDING_BATCH_SIZE) {\n const batch = texts.slice(i, i + EMBEDDING_BATCH_SIZE);\n\n try {\n const resp = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${config.apiKey}`,\n },\n body: JSON.stringify({\n model,\n input: batch.map(t => t.slice(0, 8000)),\n }),\n signal: AbortSignal.timeout(60000),\n });\n\n if (!resp.ok) {\n console.warn('Embedding 批量 API HTTP 错误:', resp.status);\n continue;\n }\n\n const json = await resp.json();\n if (json.data) {\n for (const item of json.data) {\n results[i + item.index] = new Float32Array(item.embedding);\n }\n }\n if (json.error) {\n console.warn('Embedding 批量 API 错误:', json.error.message || json.error);\n }\n } catch (e) {\n console.warn('Embedding 批量 API 调用失败:', e.message);\n }\n\n // 批次间隔,避免限流\n if (i + EMBEDDING_BATCH_SIZE < texts.length) {\n await new Promise(r => setTimeout(r, 1000));\n }\n }\n\n return results;\n}\n\n/**\n * 从 settings 读取 Embedding 配置\n * 支持独立的 embedding 配置,回退到通用 ai 配置\n */\nfunction getEmbeddingConfig() {\n const db = getDb();\n const rows = db.prepare(\n \"SELECT key, value FROM settings WHERE key IN ('embedding_provider','embedding_api_key','embedding_base_url','embedding_model','ai_provider','ai_api_key','ai_base_url')\"\n ).all();\n const config = {};\n for (const row of rows) config[row.key] = row.value;\n return {\n provider: config.embedding_provider || config.ai_provider || 'deepseek',\n apiKey: config.embedding_api_key || config.ai_api_key || '',\n baseUrl: config.embedding_base_url || '',\n model: config.embedding_model || '',\n };\n}\n\n// ===== 向量索引(使用 sqlite-vec)=====\n// 向量表用自增整数 ID,chunkMeta 映射 vectorId → {source, parentId, chunkIndex}\nlet vectorIdCounter = 0;\nlet chunkMeta = {}; // vectorId → {source, parentId, chunkIndex, chunkId}\nlet chunkIdToVectorId = {}; // chunkId → vectorId\n\nclass VectorIndex {\n constructor() {\n this.dimension = EMBEDDING_DIMENSION;\n }\n\n initTable(db) {\n ensureVec(db);\n db.exec(`\n CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_vec USING vec0(\n id INTEGER PRIMARY KEY,\n embedding float[${this.dimension}]\n );\n `);\n }\n\n rebuildTable(db) {\n ensureVec(db);\n try {\n const info = db.prepare(\"SELECT sql FROM sqlite_master WHERE name = 'knowledge_vec'\").get();\n if (info && !info.sql.includes(`float[${this.dimension}]`)) {\n console.log(`向量表维度变化,重建表 (${this.dimension}维)`);\n db.prepare('DROP TABLE IF EXISTS knowledge_vec').run();\n }\n } catch {}\n this.initTable(db);\n }\n\n upsert(db, vectorId, embedding) {\n if (!embedding) return false;\n try {\n db.prepare('DELETE FROM knowledge_vec WHERE id = ?').run(vectorId);\n db.prepare('INSERT INTO knowledge_vec (id, embedding) VALUES (?, ?)').run(\n vectorId,\n Buffer.from(embedding.buffer)\n );\n return true;\n } catch (e) {\n return false;\n }\n }\n\n search(db, queryVector, topK = 5) {\n ensureVec(db);\n if (!queryVector) return [];\n try {\n const results = db.prepare(`\n SELECT id, distance FROM knowledge_vec WHERE embedding MATCH ? ORDER BY distance LIMIT ?\n `).all(Buffer.from(queryVector.buffer), topK);\n return results.map(r => ({ vectorId: r.id, score: 1 - r.distance }));\n } catch (e) {\n return [];\n }\n }\n}\n\n// 全局实例\nlet bm25Index = null;\nconst vectorIndex = new VectorIndex();\n\n// ===== 文档拆分 (Chunking) =====\nconst CHUNK_MAX_CHARS = 400;\nconst CHUNK_OVERLAP = 50;\n\n/**\n * 将长文本按段落拆分成小块\n */\nfunction chunkText(text, maxChars = CHUNK_MAX_CHARS) {\n if (!text || text.length <= maxChars) return [text];\n\n const chunks = [];\n const paragraphs = text.split(/\\n\\s*\\n/).filter(p => p.trim());\n\n let current = '';\n for (const para of paragraphs) {\n if (current.length + para.length + 1 <= maxChars) {\n current = current ? current + '\\n\\n' + para : para;\n } else {\n if (current) chunks.push(current);\n if (para.length > maxChars) {\n const sentences = para.split(/(?<=[。!?.!?\\n])/).filter(s => s.trim());\n let sentBuf = '';\n for (const sent of sentences) {\n if (sentBuf.length + sent.length <= maxChars) {\n sentBuf += sent;\n } else {\n if (sentBuf) chunks.push(sentBuf);\n sentBuf = sent;\n }\n }\n if (sentBuf) current = sentBuf;\n else current = '';\n } else {\n current = para;\n }\n }\n }\n if (current) chunks.push(current);\n\n if (CHUNK_OVERLAP > 0 && chunks.length > 1) {\n const overlapped = [chunks[0]];\n for (let i = 1; i < chunks.length; i++) {\n const prevTail = chunks[i - 1].slice(-CHUNK_OVERLAP);\n overlapped.push(prevTail + chunks[i]);\n }\n return overlapped;\n }\n\n return chunks;\n}\n\n/**\n * 将知识条目拆分成可索引的块\n * 使用字符串复合 ID: \"{source}:{parentId}:{chunkIndex}\" 避免命名空间冲突\n */\nfunction chunkItem(item) {\n const fullText = item.content || '';\n const ns = NS[item.source] || 'k';\n\n if (fullText.length <= CHUNK_MAX_CHARS) {\n return [{\n chunkId: `${ns}:${item.id}:0`,\n source: item.source,\n parentId: item.id,\n chunkIndex: 0,\n title: item.title,\n content: item.title + ' ' + fullText,\n category: item.category,\n }];\n }\n\n const chunks = chunkText(fullText);\n return chunks.map((chunk, i) => ({\n chunkId: `${ns}:${item.id}:${i}`,\n source: item.source,\n parentId: item.id,\n chunkIndex: i,\n title: item.title + (chunks.length > 1 ? ` (第${i + 1}/${chunks.length}部分)` : ''),\n content: item.title + ' ' + chunk,\n category: item.category,\n }));\n}\n\n// 块内容缓存(chunkId → {title, content, category, source, parentId})\nlet chunkContentCache = {};\n\n/**\n * 重建索引(异步)\n * @param {boolean} forceVectors - 强制重建向量\n * @returns {Promise<number>} 索引条目数\n */\nasync function rebuildIndex(forceVectors = false) {\n const db = getDb();\n\n // 1. ai_knowledge 表\n const knowledgeItems = db.prepare(\n \"SELECT id, title, content, category FROM ai_knowledge WHERE status = 'active'\"\n ).all().map(item => ({ ...item, source: 'knowledge' }));\n\n // 2. tutorials 表(已发布教程)\n const tutorialItems = db.prepare(\n \"SELECT id, title, content, category FROM tutorials WHERE status = 'published'\"\n ).all().map(item => ({ ...item, source: 'tutorial' }));\n\n // 3. faqs 表(激活状态)\n const faqItems = db.prepare(\n \"SELECT id, question as title, answer as content, category FROM faqs WHERE status = 'active'\"\n ).all().map(item => ({ ...item, source: 'faq' }));\n\n // 合并所有数据源\n const rawItems = [...knowledgeItems, ...tutorialItems, ...faqItems];\n\n // 拆分长文档\n const allItems = [];\n let chunkCount = 0;\n for (const item of rawItems) {\n const chunks = chunkItem(item);\n allItems.push(...chunks);\n if (chunks.length > 1) chunkCount++;\n }\n if (chunkCount > 0) {\n console.log(`文档拆分: ${chunkCount} 篇长文档被拆分成 ${allItems.length} 个块`);\n }\n\n // 更新块内容缓存\n chunkContentCache = {};\n for (const item of allItems) {\n chunkContentCache[item.chunkId] = {\n title: item.title,\n content: item.content,\n category: item.category,\n source: item.source,\n parentId: item.parentId,\n };\n }\n\n // 重建 BM25\n bm25Index = new BM25Index();\n bm25Index.build(allItems.map(item => ({ ...item, id: item.chunkId })));\n\n // 重建向量(使用真实 Embedding API)\n try {\n vectorIndex.rebuildTable(db);\n\n const currentVecCount = db.prepare('SELECT COUNT(*) as cnt FROM knowledge_vec').get().cnt;\n if (!forceVectors && currentVecCount >= allItems.length) {\n console.log(`向量索引已存在 (${currentVecCount} 条),跳过重建`);\n } else {\n try { db.prepare('DELETE FROM knowledge_vec').run(); } catch {}\n\n // 重置映射\n vectorIdCounter = 0;\n chunkMeta = {};\n chunkIdToVectorId = {};\n\n const texts = allItems.map(item => item.title + ' ' + item.content);\n const embedConfig = getEmbeddingConfig();\n\n if (embedConfig.apiKey) {\n console.log(`正在调用 ${embedConfig.provider} Embedding API,共 ${texts.length} 条...`);\n const embeddings = await callEmbeddingBatch(texts, embedConfig);\n\n let successCount = 0;\n for (let i = 0; i < allItems.length; i++) {\n const vecId = ++vectorIdCounter;\n if (embeddings[i] && vectorIndex.upsert(db, vecId, embeddings[i])) {\n chunkMeta[vecId] = {\n source: allItems[i].source,\n parentId: allItems[i].parentId,\n chunkIndex: allItems[i].chunkIndex,\n chunkId: allItems[i].chunkId,\n };\n chunkIdToVectorId[allItems[i].chunkId] = vecId;\n successCount++;\n }\n }\n console.log(`向量索引已建立: ${successCount}/${allItems.length} 条成功`);\n } else {\n console.warn('未配置 AI API Key,跳过向量索引构建(仅使用 BM25)');\n }\n }\n } catch (e) {\n console.warn('向量索引初始化失败(不影响 BM25 检索):', e.message);\n }\n\n console.log(`RAG 索引已重建: ${allItems.length} 条 (知识库${knowledgeItems.length} + 教程${tutorialItems.length} + FAQ${faqItems.length})`);\n return allItems.length;\n}\n\n/**\n * 混合检索:BM25 + 向量,加权合并\n * @param {string} query - 用户问题\n * @param {number} topK - 返回条数\n * @returns {Promise<Array>} [{chunkId, title, content, score, source, parentId}]\n */\nasync function retrieve(query, topK = 5) {\n const db = getDb();\n\n if (!bm25Index) await rebuildIndex();\n\n // BM25 检索\n const bm25Results = bm25Index.search(query, topK * 2);\n\n // 向量检索\n let vecResults = [];\n try {\n const embedConfig = getEmbeddingConfig();\n if (embedConfig.apiKey) {\n const queryVector = await callEmbeddingAPI(query, embedConfig);\n if (queryVector) {\n const rawVecResults = vectorIndex.search(db, queryVector, topK * 2);\n // 将 vectorId 映射回 chunkId\n for (const r of rawVecResults) {\n const meta = chunkMeta[r.vectorId];\n if (meta) {\n vecResults.push({ id: meta.chunkId, score: r.score });\n }\n }\n }\n }\n } catch {}\n\n // 合并分数(加权:BM25 0.6 + 向量 0.4)\n const scoreMap = {};\n for (const r of bm25Results) {\n scoreMap[r.id] = (scoreMap[r.id] || 0) + r.score * 0.6;\n }\n for (const r of vecResults) {\n scoreMap[r.id] = (scoreMap[r.id] || 0) + r.score * 0.4;\n }\n\n const sorted = Object.entries(scoreMap)\n .map(([id, score]) => ({ chunkId: id, score }))\n .sort((a, b) => b.score - a.score)\n .slice(0, topK);\n\n if (sorted.length === 0) return [];\n\n // 获取详情\n const itemMap = {};\n const uncachedItems = [];\n\n for (const s of sorted) {\n if (chunkContentCache[s.chunkId]) {\n itemMap[s.chunkId] = chunkContentCache[s.chunkId];\n } else {\n uncachedItems.push(s);\n }\n }\n\n // 缓存未命中的,按来源查数据库\n const knowledgeParents = new Set();\n const tutorialParents = new Set();\n const faqParents = new Set();\n\n for (const s of uncachedItems) {\n const [source, parentId] = s.chunkId.split(':');\n const pid = Number(parentId);\n if (source === 'k') knowledgeParents.add(pid);\n else if (source === 't') tutorialParents.add(pid);\n else if (source === 'f') faqParents.add(pid);\n }\n\n if (knowledgeParents.size > 0) {\n const ids = [...knowledgeParents];\n const ph = ids.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, title, content, category FROM ai_knowledge WHERE id IN (${ph}) AND status = 'active'`).all(...ids);\n for (const r of rows) {\n itemMap[`k:${r.id}:0`] = { ...r, source: 'knowledge', parentId: r.id };\n }\n }\n if (tutorialParents.size > 0) {\n const ids = [...tutorialParents];\n const ph = ids.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, title, content, category FROM tutorials WHERE id IN (${ph}) AND status = 'published'`).all(...ids);\n for (const r of rows) {\n itemMap[`t:${r.id}:0`] = { ...r, source: 'tutorial', parentId: r.id };\n }\n }\n if (faqParents.size > 0) {\n const ids = [...faqParents];\n const ph = ids.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, question as title, answer as content, category FROM faqs WHERE id IN (${ph}) AND status = 'active'`).all(...ids);\n for (const r of rows) {\n itemMap[`f:${r.id}:0`] = { ...r, source: 'faq', parentId: r.id };\n }\n }\n\n return sorted\n .filter(s => itemMap[s.chunkId])\n .map(s => {\n const item = itemMap[s.chunkId];\n const images = [];\n const content = item.content || '';\n const mdImgs = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^)]+)\\)/g) || [];\n for (const m of mdImgs) {\n const url = m.match(/\\((https?:\\/\\/[^)]+)\\)/);\n if (url) images.push(url[1]);\n }\n const htmlImgs = content.match(/<img[^>]+src=[\"'](https?:\\/\\/[^\"']+)[\"'][^>]*>/gi) || [];\n for (const m of htmlImgs) {\n const url = m.match(/src=[\"'](https?:\\/\\/[^\"']+)[\"']/i);\n if (url) images.push(url[1]);\n }\n return {\n title: item.title,\n content: item.content,\n category: item.category,\n source: item.source,\n parentId: item.parentId,\n score: s.score,\n images: [...new Set(images)],\n };\n });\n}\n\n/**\n * 计算两个文本的相似度\n */\nfunction similarity(textA, textB) {\n const tokensA = new Set(tokenize(textA));\n const tokensB = new Set(tokenize(textB));\n if (tokensA.size === 0 || tokensB.size === 0) return 0;\n let overlap = 0;\n for (const t of tokensA) {\n if (tokensB.has(t)) overlap++;\n }\n const union = new Set([...tokensA, ...tokensB]).size;\n return union > 0 ? overlap / union : 0;\n}\n\n/**\n * 查找与给定问题相似的已有自动学习条目\n */\nasync function findSimilarLearned(question, threshold = 2.0) {\n const db = getDb();\n\n if (!bm25Index) await rebuildIndex();\n\n const bm25Results = bm25Index.search(question, 10);\n\n for (const result of bm25Results) {\n if (result.score < threshold) break;\n\n // chunkId 格式: \"k:{parentId}:{chunkIndex}\"\n const parts = result.id.split(':');\n const parentId = Number(parts[1]);\n\n const item = db.prepare(\n \"SELECT id, title, content FROM ai_knowledge WHERE id = ? AND category = '自动学习' AND status = 'active'\"\n ).get(parentId);\n\n if (item) {\n return { ...item, score: result.score };\n }\n }\n\n return null;\n}\n\n/**\n * 清理自动学习条目\n */\nfunction cleanupLearned(maxCount = 200) {\n const db = getDb();\n const items = db.prepare(\n \"SELECT id, title, content FROM ai_knowledge WHERE category = '自动学习' AND status = 'active' ORDER BY updated_at DESC\"\n ).all();\n\n if (items.length === 0) return { merged: 0, removed: 0 };\n\n let merged = 0;\n const toDelete = new Set();\n\n for (let i = 0; i < items.length; i++) {\n if (toDelete.has(items[i].id)) continue;\n for (let j = i + 1; j < items.length; j++) {\n if (toDelete.has(items[j].id)) continue;\n const score = similarity(items[i].title, items[j].title);\n if (score >= 0.4) {\n const existingContent = items[i].content;\n const newAnswer = items[j].content.replace(/^用户问题:.*?\\n\\n参考回答:/s, '');\n if (newAnswer && !existingContent.includes(newAnswer.slice(0, 50))) {\n db.prepare('UPDATE ai_knowledge SET content = content || ? WHERE id = ?')\n .run('\\n\\n补充回答:' + newAnswer, items[i].id);\n }\n toDelete.add(items[j].id);\n merged++;\n }\n }\n }\n\n if (toDelete.size > 0) {\n const placeholders = Array.from(toDelete).map(() => '?').join(',');\n db.prepare(`DELETE FROM ai_knowledge WHERE id IN (${placeholders})`).run(...toDelete);\n }\n\n let removed = 0;\n const remaining = db.prepare(\n \"SELECT COUNT(*) as cnt FROM ai_knowledge WHERE category = '自动学习' AND status = 'active'\"\n ).get().cnt;\n\n if (remaining > maxCount) {\n const excess = remaining - maxCount;\n const oldItems = db.prepare(\n \"SELECT id FROM ai_knowledge WHERE category = '自动学习' AND status = 'active' ORDER BY updated_at ASC LIMIT ?\"\n ).all(excess);\n if (oldItems.length > 0) {\n const placeholders = oldItems.map(() => '?').join(',');\n db.prepare(`DELETE FROM ai_knowledge WHERE id IN (${placeholders})`).run(...oldItems.map(i => i.id));\n removed = oldItems.length;\n }\n }\n\n return { merged, removed };\n}\n\nmodule.exports = { retrieve, rebuildIndex, tokenize, similarity, findSimilarLearned, cleanupLearned };\n"}, "3434aeeecff5dee82ac201362a02222b99c273e2": {"t": 1785766730.3266647, "n": 21042, "h": "f42e1e0875ac35f9", "c": "/**\n * RAG 检索服务 — 混合检索(BM25 + 向量)+ Re-ranking\n * 向量检索使用真实 Embedding API(DeepSeek / OpenAI 兼容)\n */\nconst { getDb } = require('../database/schema');\nconst { execSync } = require('child_process');\n\nlet sqliteVecLoaded = false;\nfunction ensureVec(db) {\n if (sqliteVecLoaded) return;\n try {\n const sqliteVec = require('sqlite-vec');\n sqliteVec.load(db);\n sqliteVecLoaded = true;\n } catch (e) {\n console.warn('sqlite-vec 加载失败:', e.message);\n }\n}\n\n// Embedding 配置\nconst EMBEDDING_DIMENSION = 512; // bge-small-zh-v1.5 输出维度\nconst EMBEDDING_BATCH_SIZE = 10; // DashScope 限制每批最多 10 条\n\n// Embedding 模型映射\nconst EMBEDDING_MODELS = {\n deepseek: 'deepseek-embedding',\n openai: 'text-embedding-3-small',\n qwen: 'text-embedding-v3',\n};\n\n// Provider 对应的 Embedding API URL\nfunction getEmbeddingUrl(provider) {\n const urls = {\n deepseek: 'https://api.deepseek.com/embeddings',\n openai: 'https://api.openai.com/v1/embeddings',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings',\n };\n return urls[provider] || '';\n}\n\n// BM25 参数\nconst BM25_K1 = 1.5;\nconst BM25_B = 0.75;\n\n// 常用同义词扩展\nconst SYNONYMS = {\n '收费': ['价格', '多少钱', '费用', '售价', '报价'],\n '价格': ['收费', '多少钱', '费用', '售价'],\n '多少钱': ['价格', '收费', '费用'],\n '购买': ['买', '下单', '订购'],\n '登录': ['登陆', '登入', '进入后台'],\n '系统': ['平台', '后台', '软件'],\n '手机': ['设备', 'AI手机'],\n '更新': ['升级', '版本更新'],\n '问题': ['故障', 'bug', '报错', '出错'],\n};\n\n// 中文分词(bigram + 同义词扩展)\nfunction tokenize(text) {\n if (!text) return [];\n const cleaned = text.replace(/[^\\u4e00-\\u9fa5a-zA-Z0-9]/g, ' ').toLowerCase();\n const tokens = [];\n // 英文单词\n const englishWords = cleaned.match(/[a-z0-9]+/g) || [];\n tokens.push(...englishWords);\n // 中文:bigram + trigram\n const chinese = cleaned.replace(/[^\\u4e00-\\u9fa5]/g, '');\n for (let i = 0; i < chinese.length - 1; i++) {\n tokens.push(chinese.slice(i, i + 2));\n }\n for (let i = 0; i < chinese.length - 2; i++) {\n tokens.push(chinese.slice(i, i + 3));\n }\n // 单字\n for (const char of chinese) {\n tokens.push(char);\n }\n // 同义词扩展\n const expanded = [];\n for (const token of tokens) {\n expanded.push(token);\n if (SYNONYMS[token]) {\n expanded.push(...SYNONYMS[token]);\n }\n }\n return expanded.filter(t => t.length > 0);\n}\n\n// BM25 索引\nclass BM25Index {\n constructor() {\n this.docs = []; // {id, tokens, length}\n this.df = {}; // document frequency\n this.totalDocs = 0;\n this.avgDocLen = 0;\n }\n\n build(items) {\n this.docs = [];\n this.df = {};\n this.totalDocs = items.length;\n let totalLen = 0;\n\n for (const item of items) {\n const tokens = tokenize(item.title + ' ' + item.content);\n this.docs.push({ id: item.id, tokens, length: tokens.length });\n totalLen += tokens.length;\n\n const uniqueTokens = new Set(tokens);\n for (const t of uniqueTokens) {\n this.df[t] = (this.df[t] || 0) + 1;\n }\n }\n this.avgDocLen = this.docs.reduce((sum, d) => sum + d.length, 0) / this.totalDocs;\n }\n\n search(query, topK = 5) {\n const queryTokens = tokenize(query);\n if (queryTokens.length === 0) return [];\n\n const scores = this.docs.map(doc => {\n let score = 0;\n const tf = {};\n for (const t of doc.tokens) tf[t] = (tf[t] || 0) + 1;\n\n for (const qt of queryTokens) {\n if (!tf[qt]) continue;\n const termFreq = tf[qt];\n const docFreq = this.df[qt] || 0;\n const idf = Math.log((this.totalDocs - docFreq + 0.5) / (docFreq + 0.5) + 1);\n const tfNorm = (termFreq * (BM25_K1 + 1)) / (termFreq + BM25_K1 * (1 - BM25_B + BM25_B * doc.length / this.avgDocLen));\n score += idf * tfNorm;\n }\n return { id: doc.id, score };\n });\n\n return scores.sort((a, b) => b.score - a.score).slice(0, topK);\n }\n}\n\n/**\n * 调用 Embedding API(同步,通过 curl)\n * @param {string} text - 要嵌入的文本\n * @param {object} config - {provider, apiKey, baseUrl, model}\n * @returns {Float32Array|null} 嵌入向量\n */\nfunction callEmbeddingAPI(text, config) {\n const url = config.baseUrl || getEmbeddingUrl(config.provider);\n if (!url || !config.apiKey) return null;\n\n const model = config.model || EMBEDDING_MODELS[config.provider] || 'deepseek-embedding';\n const body = JSON.stringify({\n model,\n input: text.slice(0, 8000), // 截断超长文本\n });\n\n try {\n const result = execSync(`curl -s -X POST \"${url}\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer ${config.apiKey}\" \\\n -d '${body.replace(/'/g, \"'\\''\")}'`, {\n encoding: 'utf-8',\n timeout: 30000,\n });\n\n const json = JSON.parse(result);\n if (json.data && json.data[0] && json.data[0].embedding) {\n return new Float32Array(json.data[0].embedding);\n }\n if (json.error) {\n console.warn('Embedding API 错误:', json.error.message || json.error);\n }\n return null;\n } catch (e) {\n console.warn('Embedding API 调用失败:', e.message);\n return null;\n }\n}\n\n/**\n * 批量调用 Embedding API\n * @param {string[]} texts - 文本数组\n * @param {object} config - API 配置\n * @returns {Float32Array[]} 嵌入向量数组(失败的用 null 占位)\n */\nfunction callEmbeddingBatch(texts, config) {\n const url = config.baseUrl || getEmbeddingUrl(config.provider);\n if (!url || !config.apiKey) return texts.map(() => null);\n\n const model = config.model || EMBEDDING_MODELS[config.provider] || 'deepseek-embedding';\n const results = new Array(texts.length).fill(null);\n\n // 分批处理\n for (let i = 0; i < texts.length; i += EMBEDDING_BATCH_SIZE) {\n const batch = texts.slice(i, i + EMBEDDING_BATCH_SIZE);\n const body = JSON.stringify({\n model,\n input: batch.map(t => t.slice(0, 8000)),\n });\n\n try {\n const result = execSync(`curl -s -X POST \"${url}\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer ${config.apiKey}\" \\\n -d '${body.replace(/'/g, \"'\\''\")}'`, {\n encoding: 'utf-8',\n timeout: 60000,\n });\n\n const json = JSON.parse(result);\n if (json.data) {\n // 按 index 排序并填充结果\n for (const item of json.data) {\n results[i + item.index] = new Float32Array(item.embedding);\n }\n }\n if (json.error) {\n console.warn('Embedding 批量 API 错误:', json.error.message || json.error);\n }\n } catch (e) {\n console.warn('Embedding 批量 API 调用失败:', e.message);\n }\n\n // 批次间隔,避免限流\n if (i + EMBEDDING_BATCH_SIZE < texts.length) {\n try { execSync('sleep 1'); } catch {}\n }\n }\n\n return results;\n}\n\n/**\n * 从 settings 读取 Embedding 配置\n * 支持独立的 embedding 配置,回退到通用 ai 配置\n */\nfunction getEmbeddingConfig() {\n const db = getDb();\n const rows = db.prepare(\n \"SELECT key, value FROM settings WHERE key IN ('embedding_provider','embedding_api_key','embedding_base_url','embedding_model','ai_provider','ai_api_key','ai_base_url')\"\n ).all();\n const config = {};\n for (const row of rows) config[row.key] = row.value;\n return {\n provider: config.embedding_provider || config.ai_provider || 'deepseek',\n apiKey: config.embedding_api_key || config.ai_api_key || '',\n baseUrl: config.embedding_base_url || '',\n model: config.embedding_model || '',\n };\n}\n\n// 向量索引(使用真实 Embedding API)\nclass VectorIndex {\n constructor() {\n this.dimension = EMBEDDING_DIMENSION;\n }\n\n initTable(db) {\n ensureVec(db);\n db.exec(`\n CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_vec USING vec0(\n id INTEGER PRIMARY KEY,\n embedding float[${this.dimension}]\n );\n `);\n }\n\n /**\n * 重建向量表(维度变化时删除重建)\n */\n rebuildTable(db) {\n ensureVec(db);\n try {\n // 检查现有表的维度\n const info = db.prepare(\"SELECT sql FROM sqlite_master WHERE name = 'knowledge_vec'\").get();\n if (info && !info.sql.includes(`float[${this.dimension}]`)) {\n console.log(`向量表维度变化,重建表 (${this.dimension}维)`);\n db.prepare('DROP TABLE IF EXISTS knowledge_vec').run();\n }\n } catch {}\n this.initTable(db);\n }\n\n upsert(db, id, text, embedding) {\n if (!embedding) return false;\n const intId = Math.floor(Number(id));\n if (isNaN(intId) || intId <= 0) return false;\n try {\n db.prepare('DELETE FROM knowledge_vec WHERE id = ' + intId).run();\n db.prepare('INSERT INTO knowledge_vec (id, embedding) VALUES (' + intId + ', ?)').run(\n Buffer.from(embedding.buffer)\n );\n return true;\n } catch (e) {\n return false;\n }\n }\n\n search(db, queryVector, topK = 5) {\n ensureVec(db);\n if (!queryVector) return [];\n try {\n const results = db.prepare(`\n SELECT id, distance FROM knowledge_vec WHERE embedding MATCH ? ORDER BY distance LIMIT ?\n `).all(Buffer.from(queryVector.buffer), topK);\n return results.map(r => ({ id: r.id, score: 1 - r.distance }));\n } catch (e) {\n return [];\n }\n }\n}\n\n// 全局实例\nlet bm25Index = null;\nconst vectorIndex = new VectorIndex();\n\n// ===== 文档拆分 (Chunking) =====\nconst CHUNK_MAX_CHARS = 400; // 每块最大字符数\nconst CHUNK_OVERLAP = 50; // 块之间重叠字符数\n\n/**\n * 将长文本按段落拆分成小块\n * 优先按段落拆分,段落太长则按句子拆分\n * @param {string} text - 原始文本\n * @param {number} maxChars - 每块最大字符数\n * @returns {string[]} 拆分后的文本块\n */\nfunction chunkText(text, maxChars = CHUNK_MAX_CHARS) {\n if (!text || text.length <= maxChars) return [text];\n\n const chunks = [];\n // 按段落拆分(双换行)\n const paragraphs = text.split(/\\n\\s*\\n/).filter(p => p.trim());\n\n let current = '';\n for (const para of paragraphs) {\n if (current.length + para.length + 1 <= maxChars) {\n current = current ? current + '\\n\\n' + para : para;\n } else {\n if (current) chunks.push(current);\n // 单个段落超长,按句子拆分\n if (para.length > maxChars) {\n const sentences = para.split(/(?<=[。!?.!?\\n])/).filter(s => s.trim());\n let sentBuf = '';\n for (const sent of sentences) {\n if (sentBuf.length + sent.length <= maxChars) {\n sentBuf += sent;\n } else {\n if (sentBuf) chunks.push(sentBuf);\n sentBuf = sent;\n }\n }\n if (sentBuf) current = sentBuf;\n else current = '';\n } else {\n current = para;\n }\n }\n }\n if (current) chunks.push(current);\n\n // 添加重叠:每块末尾保留 overlap 字符到下一块开头\n if (CHUNK_OVERLAP > 0 && chunks.length > 1) {\n const overlapped = [chunks[0]];\n for (let i = 1; i < chunks.length; i++) {\n const prevTail = chunks[i - 1].slice(-CHUNK_OVERLAP);\n overlapped.push(prevTail + chunks[i]);\n }\n return overlapped;\n }\n\n return chunks;\n}\n\n/**\n * 将知识条目拆分成可索引的块\n * 短文档不拆分,长文档按段落拆分\n * @param {object} item - {id, title, content, category, source}\n * @returns {object[]} 拆分后的块数组 [{id, title, content, source}]\n */\nfunction chunkItem(item) {\n const fullText = item.content || '';\n if (fullText.length <= CHUNK_MAX_CHARS) {\n // 短文档不拆分\n return [{\n id: item.id,\n title: item.title,\n content: item.title + ' ' + fullText,\n source: item.source,\n category: item.category,\n }];\n }\n\n const chunks = chunkText(fullText);\n return chunks.map((chunk, i) => ({\n // 用 id * 1000 + chunkIndex 作为块 ID\n id: item.id * 1000 + i,\n title: item.title + (chunks.length > 1 ? ` (第${i + 1}/${chunks.length}部分)` : ''),\n content: item.title + ' ' + chunk,\n source: item.source,\n category: item.category,\n parentId: item.id,\n }));\n}\n\n/**\n * 重建索引\n * 同时索引 ai_knowledge、tutorials(已发布)、faqs(激活状态)\n */\n// 块内容缓存(chunkId -> {title, content, category})\nlet chunkContentCache = {};\n\n/**\n * 重建索引\n * @param {boolean} forceVectors - 强制重建向量(CRUD 操作后需要)\n */\nfunction rebuildIndex(forceVectors = false) {\n const db = getDb();\n\n // 1. ai_knowledge 表\n const knowledgeItems = db.prepare(\n \"SELECT id, title, content, category FROM ai_knowledge WHERE status = 'active'\"\n ).all().map(item => ({\n ...item,\n source: 'knowledge',\n }));\n\n // 2. tutorials 表(已发布教程)\n const tutorialItems = db.prepare(\n \"SELECT id, title, content, category FROM tutorials WHERE status = 'published'\"\n ).all().map(item => ({\n ...item,\n // 教程 id 加偏移避免与 ai_knowledge 冲突\n id: 100000 + item.id,\n source: 'tutorial',\n }));\n\n // 3. faqs 表(激活状态)\n const faqItems = db.prepare(\n \"SELECT id, question as title, answer as content, category FROM faqs WHERE status = 'active'\"\n ).all().map(item => ({\n ...item,\n id: 200000 + item.id,\n source: 'faq',\n }));\n\n // 合并所有数据源\n const rawItems = [...knowledgeItems, ...tutorialItems, ...faqItems];\n\n // 拆分长文档\n const allItems = [];\n let chunkCount = 0;\n for (const item of rawItems) {\n const chunks = chunkItem(item);\n allItems.push(...chunks);\n if (chunks.length > 1) chunkCount++;\n }\n if (chunkCount > 0) {\n console.log(`文档拆分: ${chunkCount} 篇长文档被拆分成 ${allItems.length} 个块`);\n }\n\n // 更新块内容缓存\n chunkContentCache = {};\n for (const item of allItems) {\n chunkContentCache[item.id] = {\n title: item.title,\n content: item.content,\n category: item.category,\n };\n }\n\n // 重建 BM25\n bm25Index = new BM25Index();\n bm25Index.build(allItems);\n\n // 重建向量(使用真实 Embedding API)\n try {\n vectorIndex.rebuildTable(db);\n\n // 检查是否需要重建向量(避免每次启动都调 API)\n const currentVecCount = db.prepare('SELECT COUNT(*) as cnt FROM knowledge_vec').get().cnt;\n if (!forceVectors && currentVecCount >= allItems.length) {\n console.log(`向量索引已存在 (${currentVecCount} 条),跳过重建`);\n } else {\n // 清空旧数据并重建\n try { db.prepare('DELETE FROM knowledge_vec').run(); } catch {}\n\n // 准备批量文本\n const texts = allItems.map(item => item.title + ' ' + item.content);\n const embedConfig = getEmbeddingConfig();\n\n if (embedConfig.apiKey) {\n console.log(`正在调用 ${embedConfig.provider} Embedding API,共 ${texts.length} 条...`);\n const embeddings = callEmbeddingBatch(texts, embedConfig);\n\n let successCount = 0;\n for (let i = 0; i < allItems.length; i++) {\n if (embeddings[i] && vectorIndex.upsert(db, allItems[i].id, allItems[i].title, embeddings[i])) {\n successCount++;\n }\n }\n console.log(`向量索引已建立: ${successCount}/${allItems.length} 条成功`);\n } else {\n console.warn('未配置 AI API Key,跳过向量索引构建(仅使用 BM25)');\n }\n }\n } catch (e) {\n console.warn('向量索引初始化失败(不影响 BM25 检索):', e.message);\n }\n\n console.log(`RAG 索引已重建: ${allItems.length} 条 (知识库${knowledgeItems.length} + 教程${tutorialItems.length} + FAQ${faqItems.length})`);\n return allItems.length;\n}\n\n/**\n * 混合检索:BM25 + 向量,加权合并\n * @param {string} query - 用户问题\n * @param {number} topK - 返回条数\n * @returns {Array} [{id, title, content, score}]\n */\nfunction retrieve(query, topK = 5) {\n const db = getDb();\n\n // 确保索引已建\n if (!bm25Index) rebuildIndex();\n\n // BM25 检索\n const bm25Results = bm25Index.search(query, topK * 2);\n\n // 向量检索(使用真实 Embedding API)\n let vecResults = [];\n try {\n const embedConfig = getEmbeddingConfig();\n if (embedConfig.apiKey) {\n const queryVector = callEmbeddingAPI(query, embedConfig);\n if (queryVector) {\n vecResults = vectorIndex.search(db, queryVector, topK * 2);\n }\n }\n } catch {}\n\n // 合并分数(加权:BM25 0.6 + 向量 0.4)\n const scoreMap = {};\n for (const r of bm25Results) {\n scoreMap[r.id] = (scoreMap[r.id] || 0) + r.score * 0.6;\n }\n for (const r of vecResults) {\n scoreMap[r.id] = (scoreMap[r.id] || 0) + r.score * 0.4;\n }\n\n // 排序取 Top-K\n const sorted = Object.entries(scoreMap)\n .map(([id, score]) => ({ id: Number(id), score }))\n .sort((a, b) => b.score - a.score)\n .slice(0, topK);\n\n // 获取知识条目详情\n if (sorted.length === 0) return [];\n\n const itemMap = {};\n\n // 先从块缓存中查找\n const uncachedIds = [];\n for (const s of sorted) {\n if (chunkContentCache[s.id]) {\n itemMap[s.id] = chunkContentCache[s.id];\n } else {\n uncachedIds.push(s);\n }\n }\n\n // 缓存未命中的,按 ID 范围查数据库\n const knowledgeIds = uncachedIds.filter(s => s.id < 100000).map(s => s.id);\n const tutorialIds = uncachedIds.filter(s => s.id >= 100000 && s.id < 200000).map(s => s.id - 100000);\n const faqIds = uncachedIds.filter(s => s.id >= 200000).map(s => s.id - 200000);\n\n if (knowledgeIds.length > 0) {\n const ph = knowledgeIds.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, title, content, category FROM ai_knowledge WHERE id IN (${ph}) AND status = 'active'`).all(...knowledgeIds);\n for (const r of rows) itemMap[r.id] = r;\n }\n if (tutorialIds.length > 0) {\n const ph = tutorialIds.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, title, content, category FROM tutorials WHERE id IN (${ph}) AND status = 'published'`).all(...tutorialIds);\n for (const r of rows) itemMap[100000 + r.id] = r;\n }\n if (faqIds.length > 0) {\n const ph = faqIds.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, question as title, answer as content, category FROM faqs WHERE id IN (${ph}) AND status = 'active'`).all(...faqIds);\n for (const r of rows) itemMap[200000 + r.id] = r;\n }\n\n return sorted\n .filter(s => itemMap[s.id])\n .map(s => {\n const item = itemMap[s.id];\n // 从内容中提取图片 URL\n const images = [];\n const content = item.content || '';\n // Markdown 图片: ![alt](url)\n const mdImgs = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^)]+)\\)/g) || [];\n for (const m of mdImgs) {\n const url = m.match(/\\((https?:\\/\\/[^)]+)\\)/);\n if (url) images.push(url[1]);\n }\n // HTML img: <img src=\"url\">\n const htmlImgs = content.match(/<img[^>]+src=[\"'](https?:\\/\\/[^\"']+)[\"'][^>]*>/gi) || [];\n for (const m of htmlImgs) {\n const url = m.match(/src=[\"'](https?:\\/\\/[^\"']+)[\"']/i);\n if (url) images.push(url[1]);\n }\n return { ...item, score: s.score, images: [...new Set(images)] };\n });\n}\n\n/**\n * 计算两个文本的相似度(基于 token 重叠率)\n * @returns {number} 0-1 之间的相似度\n */\nfunction similarity(textA, textB) {\n const tokensA = new Set(tokenize(textA));\n const tokensB = new Set(tokenize(textB));\n if (tokensA.size === 0 || tokensB.size === 0) return 0;\n let overlap = 0;\n for (const t of tokensA) {\n if (tokensB.has(t)) overlap++;\n }\n // Jaccard 相似度\n const union = new Set([...tokensA, ...tokensB]).size;\n return union > 0 ? overlap / union : 0;\n}\n\n/**\n * 查找与给定问题相似的已有自动学习条目(使用语义匹配,非 token 匹配)\n * @param {string} question - 用户问题\n * @param {number} threshold - 相似度阈值(默认 2.0,BM25 分数)\n * @returns {object|null} 相似条目或 null\n */\nfunction findSimilarLearned(question, threshold = 2.0) {\n const db = getDb();\n\n // 确保索引已建\n if (!bm25Index) rebuildIndex();\n\n // 用 BM25 语义检索(已包含同义词扩展)\n const bm25Results = bm25Index.search(question, 10);\n\n // 过滤出自动学习的条目\n for (const result of bm25Results) {\n if (result.score < threshold) break;\n\n // 块 ID -> 原始 ID\n const parentId = result.id >= 100000000 ? Math.floor(result.id / 1000) : result.id;\n\n // 查数据库确认是自动学习条目\n const item = db.prepare(\n \"SELECT id, title, content FROM ai_knowledge WHERE id = ? AND category = '自动学习' AND status = 'active'\"\n ).get(parentId);\n\n if (item) {\n return { ...item, score: result.score };\n }\n }\n\n return null;\n}\n\n/**\n * 清理自动学习条目:合并相似内容,淘汰超限条目\n * @param {number} maxCount - 最大保留条数(默认 200)\n */\nfunction cleanupLearned(maxCount = 200) {\n const db = getDb();\n const items = db.prepare(\n \"SELECT id, title, content FROM ai_knowledge WHERE category = '自动学习' AND status = 'active' ORDER BY updated_at DESC\"\n ).all();\n\n if (items.length === 0) return { merged: 0, removed: 0 };\n\n let merged = 0;\n const toDelete = new Set();\n\n // 第一步:合并相似条目\n for (let i = 0; i < items.length; i++) {\n if (toDelete.has(items[i].id)) continue;\n for (let j = i + 1; j < items.length; j++) {\n if (toDelete.has(items[j].id)) continue;\n const score = similarity(items[i].title, items[j].title);\n if (score >= 0.4) {\n // 把 j 的回答追加到 i,删除 j\n const existingContent = items[i].content;\n const newAnswer = items[j].content.replace(/^用户问题:.*?\\n\\n参考回答:/s, '');\n if (newAnswer && !existingContent.includes(newAnswer.slice(0, 50))) {\n db.prepare('UPDATE ai_knowledge SET content = content || ? WHERE id = ?')\n .run('\\n\\n补充回答:' + newAnswer, items[i].id);\n }\n toDelete.add(items[j].id);\n merged++;\n }\n }\n }\n\n // 批量删除被合并的条目\n if (toDelete.size > 0) {\n const placeholders = Array.from(toDelete).map(() => '?').join(',');\n db.prepare(`DELETE FROM ai_knowledge WHERE id IN (${placeholders})`).run(...toDelete);\n }\n\n // 第二步:超限淘汰(删除最旧的)\n let removed = 0;\n const remaining = db.prepare(\n \"SELECT COUNT(*) as cnt FROM ai_knowledge WHERE category = '自动学习' AND status = 'active'\"\n ).get().cnt;\n\n if (remaining > maxCount) {\n const excess = remaining - maxCount;\n const oldItems = db.prepare(\n \"SELECT id FROM ai_knowledge WHERE category = '自动学习' AND status = 'active' ORDER BY updated_at ASC LIMIT ?\"\n ).all(excess);\n if (oldItems.length > 0) {\n const placeholders = oldItems.map(() => '?').join(',');\n db.prepare(`DELETE FROM ai_knowledge WHERE id IN (${placeholders})`).run(...oldItems.map(i => i.id));\n removed = oldItems.length;\n }\n }\n\n return { merged, removed };\n}\n\nmodule.exports = { retrieve, rebuildIndex, tokenize, similarity, findSimilarLearned, cleanupLearned };\n"}, "5d69dda26605c9190e699ef758b2b9224a7b823b": {"t": 1785766731.6512063, "n": 12312, "h": "d1f345c3503df612", "c": "/**\n * RAG 检索服务 — 混合检索(BM25 + 向量)+ Re-ranking\n */\nconst { getDb } = require('../database/schema');\nlet sqliteVecLoaded = false;\nfunction ensureVec(db) {\n if (sqliteVecLoaded) return;\n try {\n const sqliteVec = require('sqlite-vec');\n sqliteVec.load(db);\n sqliteVecLoaded = true;\n } catch (e) {\n console.warn('sqlite-vec 加载失败:', e.message);\n }\n}\n\n// BM25 参数\nconst BM25_K1 = 1.5;\nconst BM25_B = 0.75;\n\n// 常用同义词扩展\nconst SYNONYMS = {\n '收费': ['价格', '多少钱', '费用', '售价', '报价'],\n '价格': ['收费', '多少钱', '费用', '售价'],\n '多少钱': ['价格', '收费', '费用'],\n '购买': ['买', '下单', '订购'],\n '登录': ['登陆', '登入', '进入后台'],\n '系统': ['平台', '后台', '软件'],\n '手机': ['设备', 'AI手机'],\n '更新': ['升级', '版本更新'],\n '问题': ['故障', 'bug', '报错', '出错'],\n};\n\n// 中文分词(bigram + 同义词扩展)\nfunction tokenize(text) {\n if (!text) return [];\n const cleaned = text.replace(/[^\\u4e00-\\u9fa5a-zA-Z0-9]/g, ' ').toLowerCase();\n const tokens = [];\n // 英文单词\n const englishWords = cleaned.match(/[a-z0-9]+/g) || [];\n tokens.push(...englishWords);\n // 中文:bigram + trigram\n const chinese = cleaned.replace(/[^\\u4e00-\\u9fa5]/g, '');\n for (let i = 0; i < chinese.length - 1; i++) {\n tokens.push(chinese.slice(i, i + 2));\n }\n for (let i = 0; i < chinese.length - 2; i++) {\n tokens.push(chinese.slice(i, i + 3));\n }\n // 单字\n for (const char of chinese) {\n tokens.push(char);\n }\n // 同义词扩展\n const expanded = [];\n for (const token of tokens) {\n expanded.push(token);\n if (SYNONYMS[token]) {\n expanded.push(...SYNONYMS[token]);\n }\n }\n return expanded.filter(t => t.length > 0);\n}\n\n// BM25 索引\nclass BM25Index {\n constructor() {\n this.docs = []; // {id, tokens, length}\n this.df = {}; // document frequency\n this.totalDocs = 0;\n this.avgDocLen = 0;\n }\n\n build(items) {\n this.docs = [];\n this.df = {};\n this.totalDocs = items.length;\n let totalLen = 0;\n\n for (const item of items) {\n const tokens = tokenize(item.title + ' ' + item.content);\n this.docs.push({ id: item.id, tokens, length: tokens.length });\n totalLen += tokens.length;\n\n const uniqueTokens = new Set(tokens);\n for (const t of uniqueTokens) {\n this.df[t] = (this.df[t] || 0) + 1;\n }\n }\n this.avgDocLen = this.docs.reduce((sum, d) => sum + d.length, 0) / this.totalDocs;\n }\n\n search(query, topK = 5) {\n const queryTokens = tokenize(query);\n if (queryTokens.length === 0) return [];\n\n const scores = this.docs.map(doc => {\n let score = 0;\n const tf = {};\n for (const t of doc.tokens) tf[t] = (tf[t] || 0) + 1;\n\n for (const qt of queryTokens) {\n if (!tf[qt]) continue;\n const termFreq = tf[qt];\n const docFreq = this.df[qt] || 0;\n const idf = Math.log((this.totalDocs - docFreq + 0.5) / (docFreq + 0.5) + 1);\n const tfNorm = (termFreq * (BM25_K1 + 1)) / (termFreq + BM25_K1 * (1 - BM25_B + BM25_B * doc.length / this.avgDocLen));\n score += idf * tfNorm;\n }\n return { id: doc.id, score };\n });\n\n return scores.sort((a, b) => b.score - a.score).slice(0, topK);\n }\n}\n\n// 向量索引(使用 sqlite-vec)\nclass VectorIndex {\n constructor() {\n this.dimension = 128; // 特征哈希维度\n }\n\n /**\n * 简单特征哈希:将文本转为固定维度向量\n * 用 SimHash 思想,对中文和英文都有效\n */\n textToVector(text) {\n const tokens = tokenize(text);\n const vector = new Float32Array(this.dimension);\n for (let i = 0; i < tokens.length; i++) {\n const token = tokens[i];\n // 简单哈希\n let hash = 0;\n for (let j = 0; j < token.length; j++) {\n hash = ((hash << 5) - hash + token.charCodeAt(j)) | 0;\n }\n const idx = Math.abs(hash) % this.dimension;\n // 使用 +/-1 投影(特征哈希)\n vector[idx] += (hash > 0 ? 1 : -1);\n }\n // L2 归一化\n let norm = 0;\n for (let i = 0; i < this.dimension; i++) norm += vector[i] * vector[i];\n norm = Math.sqrt(norm) || 1;\n for (let i = 0; i < this.dimension; i++) vector[i] /= norm;\n return vector;\n }\n\n initTable(db) {\n ensureVec(db);\n db.exec(`\n CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_vec USING vec0(\n id INTEGER PRIMARY KEY,\n embedding float[${this.dimension}]\n );\n `);\n }\n\n upsert(db, id, text) {\n const vector = this.textToVector(text);\n const intId = Math.floor(Number(id));\n if (isNaN(intId) || intId <= 0) return;\n // 先删除旧的\n try { db.prepare('DELETE FROM knowledge_vec WHERE id = ' + intId).run(); } catch {}\n // 插入新的(sqlite-vec 要求主键必须是字面量整数,不能用参数绑定)\n db.prepare('INSERT INTO knowledge_vec (id, embedding) VALUES (' + intId + ', ?)').run(Buffer.from(vector.buffer));\n }\n\n search(db, queryText, topK = 5) {\n ensureVec(db);\n const queryVector = this.textToVector(queryText);\n try {\n const results = db.prepare(`\n SELECT id, distance FROM knowledge_vec WHERE embedding MATCH ? ORDER BY distance LIMIT ?\n `).all(Buffer.from(queryVector.buffer), topK);\n return results.map(r => ({ id: r.id, score: 1 - r.distance })); // distance -> similarity\n } catch (e) {\n // 如果向量表还没建好,返回空\n return [];\n }\n }\n}\n\n// 全局实例\nlet bm25Index = null;\nconst vectorIndex = new VectorIndex();\n\n/**\n * 重建索引\n * 同时索引 ai_knowledge、tutorials(已发布)、faqs(激活状态)\n */\nfunction rebuildIndex() {\n const db = getDb();\n\n // 1. ai_knowledge 表\n const knowledgeItems = db.prepare(\n \"SELECT id, title, content, category FROM ai_knowledge WHERE status = 'active'\"\n ).all().map(item => ({\n ...item,\n source: 'knowledge',\n }));\n\n // 2. tutorials 表(已发布教程)\n const tutorialItems = db.prepare(\n \"SELECT id, title, content, category FROM tutorials WHERE status = 'published'\"\n ).all().map(item => ({\n ...item,\n // 教程 id 加偏移避免与 ai_knowledge 冲突\n id: 100000 + item.id,\n source: 'tutorial',\n }));\n\n // 3. faqs 表(激活状态)\n const faqItems = db.prepare(\n \"SELECT id, question as title, answer as content, category FROM faqs WHERE status = 'active'\"\n ).all().map(item => ({\n ...item,\n id: 200000 + item.id,\n source: 'faq',\n }));\n\n // 合并所有数据源\n const allItems = [...knowledgeItems, ...tutorialItems, ...faqItems];\n\n // 重建 BM25\n bm25Index = new BM25Index();\n bm25Index.build(allItems);\n\n // 重建向量\n try {\n vectorIndex.initTable(db);\n // 清空旧数据\n try { db.prepare('DELETE FROM knowledge_vec').run(); } catch {}\n let vecCount = 0;\n for (const item of allItems) {\n try {\n vectorIndex.upsert(db, item.id, item.title + ' ' + item.content);\n vecCount++;\n } catch (e) {\n // 单条失败不影响其他条目\n }\n }\n if (vecCount < allItems.length) {\n console.warn('向量索引: ' + (allItems.length - vecCount) + ' 条插入失败,已跳过');\n }\n } catch (e) {\n console.warn('向量索引初始化失败(不影响 BM25 检索):', e.message);\n }\n\n console.log(`RAG 索引已重建: ${allItems.length} 条 (知识库${knowledgeItems.length} + 教程${tutorialItems.length} + FAQ${faqItems.length})`);\n return allItems.length;\n}\n\n/**\n * 混合检索:BM25 + 向量,加权合并\n * @param {string} query - 用户问题\n * @param {number} topK - 返回条数\n * @returns {Array} [{id, title, content, score}]\n */\nfunction retrieve(query, topK = 5) {\n const db = getDb();\n\n // 确保索引已建\n if (!bm25Index) rebuildIndex();\n\n // BM25 检索\n const bm25Results = bm25Index.search(query, topK * 2);\n\n // 向量检索\n let vecResults = [];\n try {\n vecResults = vectorIndex.search(db, query, topK * 2);\n } catch {}\n\n // 合并分数(加权:BM25 0.6 + 向量 0.4)\n const scoreMap = {};\n for (const r of bm25Results) {\n scoreMap[r.id] = (scoreMap[r.id] || 0) + r.score * 0.6;\n }\n for (const r of vecResults) {\n scoreMap[r.id] = (scoreMap[r.id] || 0) + r.score * 0.4;\n }\n\n // 排序取 Top-K\n const sorted = Object.entries(scoreMap)\n .map(([id, score]) => ({ id: Number(id), score }))\n .sort((a, b) => b.score - a.score)\n .slice(0, topK);\n\n // 获取知识条目详情(根据 ID 范围区分数据源)\n if (sorted.length === 0) return [];\n\n const itemMap = {};\n\n // 按 ID 范围分组查询\n const knowledgeIds = sorted.filter(s => s.id < 100000).map(s => s.id);\n const tutorialIds = sorted.filter(s => s.id >= 100000 && s.id < 200000).map(s => s.id - 100000);\n const faqIds = sorted.filter(s => s.id >= 200000).map(s => s.id - 200000);\n\n if (knowledgeIds.length > 0) {\n const ph = knowledgeIds.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, title, content, category FROM ai_knowledge WHERE id IN (${ph}) AND status = 'active'`).all(...knowledgeIds);\n for (const r of rows) itemMap[r.id] = r;\n }\n if (tutorialIds.length > 0) {\n const ph = tutorialIds.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, title, content, category FROM tutorials WHERE id IN (${ph}) AND status = 'published'`).all(...tutorialIds);\n for (const r of rows) itemMap[100000 + r.id] = r;\n }\n if (faqIds.length > 0) {\n const ph = faqIds.map(() => '?').join(',');\n const rows = db.prepare(`SELECT id, question as title, answer as content, category FROM faqs WHERE id IN (${ph}) AND status = 'active'`).all(...faqIds);\n for (const r of rows) itemMap[200000 + r.id] = r;\n }\n\n return sorted\n .filter(s => itemMap[s.id])\n .map(s => ({ ...itemMap[s.id], score: s.score }));\n}\n\n/**\n * 计算两个文本的相似度(基于 token 重叠率)\n * @returns {number} 0-1 之间的相似度\n */\nfunction similarity(textA, textB) {\n const tokensA = new Set(tokenize(textA));\n const tokensB = new Set(tokenize(textB));\n if (tokensA.size === 0 || tokensB.size === 0) return 0;\n let overlap = 0;\n for (const t of tokensA) {\n if (tokensB.has(t)) overlap++;\n }\n // Jaccard 相似度\n const union = new Set([...tokensA, ...tokensB]).size;\n return union > 0 ? overlap / union : 0;\n}\n\n/**\n * 查找与给定问题相似的已有自动学习条目\n * @param {string} question - 用户问题\n * @param {number} threshold - 相似度阈值(默认 0.35)\n * @returns {object|null} 相似条目或 null\n */\nfunction findSimilarLearned(question, threshold = 0.35) {\n const db = getDb();\n const existing = db.prepare(\n \"SELECT id, title, content FROM ai_knowledge WHERE category = '自动学习' AND status = 'active'\"\n ).all();\n\n let bestMatch = null;\n let bestScore = 0;\n\n for (const item of existing) {\n const score = similarity(question, item.title);\n if (score > bestScore && score >= threshold) {\n bestScore = score;\n bestMatch = item;\n }\n }\n\n return bestMatch ? { ...bestMatch, score: bestScore } : null;\n}\n\n/**\n * 清理自动学习条目:合并相似内容,淘汰超限条目\n * @param {number} maxCount - 最大保留条数(默认 200)\n */\nfunction cleanupLearned(maxCount = 200) {\n const db = getDb();\n const items = db.prepare(\n \"SELECT id, title, content FROM ai_knowledge WHERE category = '自动学习' AND status = 'active' ORDER BY updated_at DESC\"\n ).all();\n\n if (items.length === 0) return { merged: 0, removed: 0 };\n\n let merged = 0;\n const toDelete = new Set();\n\n // 第一步:合并相似条目\n for (let i = 0; i < items.length; i++) {\n if (toDelete.has(items[i].id)) continue;\n for (let j = i + 1; j < items.length; j++) {\n if (toDelete.has(items[j].id)) continue;\n const score = similarity(items[i].title, items[j].title);\n if (score >= 0.4) {\n // 把 j 的回答追加到 i,删除 j\n const existingContent = items[i].content;\n const newAnswer = items[j].content.replace(/^用户问题:.*?\\n\\n参考回答:/s, '');\n if (newAnswer && !existingContent.includes(newAnswer.slice(0, 50))) {\n db.prepare('UPDATE ai_knowledge SET content = content || ? WHERE id = ?')\n .run('\\n\\n补充回答:' + newAnswer, items[i].id);\n }\n toDelete.add(items[j].id);\n merged++;\n }\n }\n }\n\n // 批量删除被合并的条目\n if (toDelete.size > 0) {\n const placeholders = Array.from(toDelete).map(() => '?').join(',');\n db.prepare(`DELETE FROM ai_knowledge WHERE id IN (${placeholders})`).run(...toDelete);\n }\n\n // 第二步:超限淘汰(删除最旧的)\n let removed = 0;\n const remaining = db.prepare(\n \"SELECT COUNT(*) as cnt FROM ai_knowledge WHERE category = '自动学习' AND status = 'active'\"\n ).get().cnt;\n\n if (remaining > maxCount) {\n const excess = remaining - maxCount;\n const oldItems = db.prepare(\n \"SELECT id FROM ai_knowledge WHERE category = '自动学习' AND status = 'active' ORDER BY updated_at ASC LIMIT ?\"\n ).all(excess);\n if (oldItems.length > 0) {\n const placeholders = oldItems.map(() => '?').join(',');\n db.prepare(`DELETE FROM ai_knowledge WHERE id IN (${placeholders})`).run(...oldItems.map(i => i.id));\n removed = oldItems.length;\n }\n }\n\n return { merged, removed };\n}\n\nmodule.exports = { retrieve, rebuildIndex, tokenize, similarity, findSimilarLearned, cleanupLearned };\n"}, "f882ddb355bf9ea7f8eff6c1d5cd683e145d0aec": {"t": 1785766732.961888, "n": 7059, "h": "e27f50ac2c752407", "c": "/**\n * RAG 检索服务 — 混合检索(BM25 + 向量)+ Re-ranking\n */\nconst { getDb } = require('../database/schema');\nlet sqliteVecLoaded = false;\nfunction ensureVec(db) {\n if (sqliteVecLoaded) return;\n try {\n const sqliteVec = require('sqlite-vec');\n sqliteVec.load(db);\n sqliteVecLoaded = true;\n } catch (e) {\n console.warn('sqlite-vec 加载失败:', e.message);\n }\n}\n\n// BM25 参数\nconst BM25_K1 = 1.5;\nconst BM25_B = 0.75;\n\n// 常用同义词扩展\nconst SYNONYMS = {\n '收费': ['价格', '多少钱', '费用', '售价', '报价'],\n '价格': ['收费', '多少钱', '费用', '售价'],\n '多少钱': ['价格', '收费', '费用'],\n '购买': ['买', '下单', '订购'],\n '登录': ['登陆', '登入', '进入后台'],\n '系统': ['平台', '后台', '软件'],\n '手机': ['设备', 'AI手机'],\n '更新': ['升级', '版本更新'],\n '问题': ['故障', 'bug', '报错', '出错'],\n};\n\n// 中文分词(bigram + 同义词扩展)\nfunction tokenize(text) {\n if (!text) return [];\n const cleaned = text.replace(/[^\\u4e00-\\u9fa5a-zA-Z0-9]/g, ' ').toLowerCase();\n const tokens = [];\n // 英文单词\n const englishWords = cleaned.match(/[a-z0-9]+/g) || [];\n tokens.push(...englishWords);\n // 中文:bigram + trigram\n const chinese = cleaned.replace(/[^\\u4e00-\\u9fa5]/g, '');\n for (let i = 0; i < chinese.length - 1; i++) {\n tokens.push(chinese.slice(i, i + 2));\n }\n for (let i = 0; i < chinese.length - 2; i++) {\n tokens.push(chinese.slice(i, i + 3));\n }\n // 单字\n for (const char of chinese) {\n tokens.push(char);\n }\n // 同义词扩展\n const expanded = [];\n for (const token of tokens) {\n expanded.push(token);\n if (SYNONYMS[token]) {\n expanded.push(...SYNONYMS[token]);\n }\n }\n return expanded.filter(t => t.length > 0);\n}\n\n// BM25 索引\nclass BM25Index {\n constructor() {\n this.docs = []; // {id, tokens, length}\n this.df = {}; // document frequency\n this.totalDocs = 0;\n this.avgDocLen = 0;\n }\n\n build(items) {\n this.docs = [];\n this.df = {};\n this.totalDocs = items.length;\n let totalLen = 0;\n\n for (const item of items) {\n const tokens = tokenize(item.title + ' ' + item.content);\n this.docs.push({ id: item.id, tokens, length: tokens.length });\n totalLen += tokens.length;\n\n const uniqueTokens = new Set(tokens);\n for (const t of uniqueTokens) {\n this.df[t] = (this.df[t] || 0) + 1;\n }\n }\n this.avgDocLen = this.docs.reduce((sum, d) => sum + d.length, 0) / this.totalDocs;\n }\n\n search(query, topK = 5) {\n const queryTokens = tokenize(query);\n if (queryTokens.length === 0) return [];\n\n const scores = this.docs.map(doc => {\n let score = 0;\n const tf = {};\n for (const t of doc.tokens) tf[t] = (tf[t] || 0) + 1;\n\n for (const qt of queryTokens) {\n if (!tf[qt]) continue;\n const termFreq = tf[qt];\n const docFreq = this.df[qt] || 0;\n const idf = Math.log((this.totalDocs - docFreq + 0.5) / (docFreq + 0.5) + 1);\n const tfNorm = (termFreq * (BM25_K1 + 1)) / (termFreq + BM25_K1 * (1 - BM25_B + BM25_B * doc.length / this.avgDocLen));\n score += idf * tfNorm;\n }\n return { id: doc.id, score };\n });\n\n return scores.sort((a, b) => b.score - a.score).slice(0, topK);\n }\n}\n\n// 向量索引(使用 sqlite-vec)\nclass VectorIndex {\n constructor() {\n this.dimension = 128; // 特征哈希维度\n }\n\n /**\n * 简单特征哈希:将文本转为固定维度向量\n * 用 SimHash 思想,对中文和英文都有效\n */\n textToVector(text) {\n const tokens = tokenize(text);\n const vector = new Float32Array(this.dimension);\n for (let i = 0; i < tokens.length; i++) {\n const token = tokens[i];\n // 简单哈希\n let hash = 0;\n for (let j = 0; j < token.length; j++) {\n hash = ((hash << 5) - hash + token.charCodeAt(j)) | 0;\n }\n const idx = Math.abs(hash) % this.dimension;\n // 使用 +/-1 投影(特征哈希)\n vector[idx] += (hash > 0 ? 1 : -1);\n }\n // L2 归一化\n let norm = 0;\n for (let i = 0; i < this.dimension; i++) norm += vector[i] * vector[i];\n norm = Math.sqrt(norm) || 1;\n for (let i = 0; i < this.dimension; i++) vector[i] /= norm;\n return vector;\n }\n\n initTable(db) {\n ensureVec(db);\n db.exec(`\n CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_vec USING vec0(\n id INTEGER PRIMARY KEY,\n embedding float[${this.dimension}]\n );\n `);\n }\n\n upsert(db, id, text) {\n const vector = this.textToVector(text);\n const intId = Number(id);\n if (isNaN(intId)) return;\n // 先删除旧的\n try { db.prepare('DELETE FROM knowledge_vec WHERE id = ?').run(intId); } catch {}\n // 插入新的\n db.prepare('INSERT INTO knowledge_vec (id, embedding) VALUES (?, ?)').run(intId, Buffer.from(vector.buffer));\n }\n\n search(db, queryText, topK = 5) {\n ensureVec(db);\n const queryVector = this.textToVector(queryText);\n try {\n const results = db.prepare(`\n SELECT id, distance FROM knowledge_vec WHERE embedding MATCH ? ORDER BY distance LIMIT ?\n `).all(Buffer.from(queryVector.buffer), topK);\n return results.map(r => ({ id: r.id, score: 1 - r.distance })); // distance -> similarity\n } catch (e) {\n // 如果向量表还没建好,返回空\n return [];\n }\n }\n}\n\n// 全局实例\nlet bm25Index = null;\nconst vectorIndex = new VectorIndex();\n\n/**\n * 重建索引\n */\nfunction rebuildIndex() {\n const db = getDb();\n const items = db.prepare(\"SELECT id, title, content FROM ai_knowledge WHERE status = 'active'\").all();\n\n // 重建 BM25\n bm25Index = new BM25Index();\n bm25Index.build(items);\n\n // 重建向量\n try {\n vectorIndex.initTable(db);\n // 清空旧数据\n try { db.prepare('DELETE FROM knowledge_vec').run(); } catch {}\n for (const item of items) {\n vectorIndex.upsert(db, item.id, item.title + ' ' + item.content);\n }\n } catch (e) {\n console.warn('向量索引初始化失败(不影响 BM25 检索):', e.message);\n }\n\n console.log(`RAG 索引已重建: ${items.length} 条知识`);\n return items.length;\n}\n\n/**\n * 混合检索:BM25 + 向量,加权合并\n * @param {string} query - 用户问题\n * @param {number} topK - 返回条数\n * @returns {Array} [{id, title, content, score}]\n */\nfunction retrieve(query, topK = 5) {\n const db = getDb();\n\n // 确保索引已建\n if (!bm25Index) rebuildIndex();\n\n // BM25 检索\n const bm25Results = bm25Index.search(query, topK * 2);\n\n // 向量检索\n let vecResults = [];\n try {\n vecResults = vectorIndex.search(db, query, topK * 2);\n } catch {}\n\n // 合并分数(加权:BM25 0.6 + 向量 0.4)\n const scoreMap = {};\n for (const r of bm25Results) {\n scoreMap[r.id] = (scoreMap[r.id] || 0) + r.score * 0.6;\n }\n for (const r of vecResults) {\n scoreMap[r.id] = (scoreMap[r.id] || 0) + r.score * 0.4;\n }\n\n // 排序取 Top-K\n const sorted = Object.entries(scoreMap)\n .map(([id, score]) => ({ id: Number(id), score }))\n .sort((a, b) => b.score - a.score)\n .slice(0, topK);\n\n // 获取知识条目详情\n if (sorted.length === 0) return [];\n const placeholders = sorted.map(() => '?').join(',');\n const items = db.prepare(`SELECT id, title, content, category FROM ai_knowledge WHERE id IN (${placeholders}) AND status = 'active'`)\n .all(...sorted.map(s => s.id));\n\n // 按分数排序\n const itemMap = {};\n for (const item of items) itemMap[item.id] = item;\n return sorted\n .filter(s => itemMap[s.id])\n .map(s => ({ ...itemMap[s.id], score: s.score }));\n}\n\nmodule.exports = { retrieve, rebuildIndex, tokenize };\n"}}, "backend/services/ai.js": {"ff8d17b14023e5654fa002ec3900c26e454165c2": {"t": 1785746698.8499682, "n": 9416, "h": "634f819f92493b3e", "c": "/**\n * AI 客服服务 — 调用 LLM API 回答用户问题\n */\nconst https = require('https');\nconst { getDb } = require('../database/schema');\nconst { retrieve: ragRetrieve, rebuildIndex } = require('./rag');\n\n/**\n * 从 settings 获取 AI 配置\n */\nfunction getAIConfig() {\n const db = getDb();\n const rows = db.prepare(\"SELECT key, value FROM settings WHERE key IN ('ai_provider','ai_api_key','ai_model','ai_system_prompt','ai_base_url')\").all();\n const config = {};\n for (const row of rows) config[row.key] = row.value;\n return config;\n}\n\n/**\n * RAG 检索:根据用户问题检索相关知识(支持图片)\n */\nasync function getKnowledgeContext(query) {\n if (!query) return '';\n try {\n const results = await ragRetrieve(query, 5);\n if (results.length === 0) return '';\n let context = '以下是与用户问题最相关的知识库内容,请基于这些信息回答:\\n\\n';\n for (const item of results) {\n context += `【${item.category || '未分类'}】${item.title}\\n${item.content}\\n`;\n // 附带来源链接(教程/FAQ)\n if (item.source === 'tutorial' && item.parentId) {\n context += `📖 查看教程详情:[点击这里](/tutorials/${item.parentId})\\n`;\n } else if (item.source === 'faq') {\n context += `❓ 更多常见问题:[查看FAQ](/faq)\\n`;\n }\n // 提取内容中的外部链接\n const urls = (item.content || '').match(/https?:\\/\\/[^\\s\"'<>)\\]]+/g) || [];\n const uniqueUrls = [...new Set(urls)];\n if (uniqueUrls.length > 0) {\n context += `🔗 相关链接:${uniqueUrls.map(u => `[${u}](${u})`).join('、')}\\n`;\n }\n // 附带相关图片\n if (item.images && item.images.length > 0) {\n context += `相关图片:${item.images.join(', ')}\\n`;\n }\n context += '\\n';\n }\n return context;\n } catch (e) {\n console.warn('RAG 检索失败:', e.message);\n return '';\n }\n}\n\n/**\n * 调用 OpenAI 兼容 API\n */\nfunction callOpenAICompat(baseUrl, apiKey, model, messages) {\n return new Promise((resolve, reject) => {\n const parsed = new URL(baseUrl);\n const body = JSON.stringify({\n model,\n messages,\n temperature: 0.7,\n max_tokens: 2000,\n });\n const options = {\n hostname: parsed.hostname,\n port: parsed.port || 443,\n path: parsed.pathname.replace(/\\/$/, '') + '/chat/completions',\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Length': Buffer.byteLength(body),\n },\n };\n const req = https.request(options, res => {\n let data = '';\n res.on('data', c => data += c);\n res.on('end', () => {\n try {\n const json = JSON.parse(data);\n if (json.error) return reject(new Error(json.error.message || JSON.stringify(json.error)));\n const content = json.choices?.[0]?.message?.content || '';\n resolve(content);\n } catch (e) {\n reject(new Error('AI 响应解析失败: ' + data.slice(0, 200)));\n }\n });\n });\n req.on('error', reject);\n req.write(body);\n req.end();\n });\n}\n\n/**\n * 获取 provider 的 baseUrl\n */\nfunction getProviderBaseUrl(provider) {\n const urls = {\n deepseek: 'https://api.deepseek.com',\n openai: 'https://api.openai.com/v1',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n };\n return urls[provider] || '';\n}\n\n/**\n * AI 对话主函数(三层记忆:用户画像 + 历史对话 + 全局知识库)\n */\nasync function chat(history, userMessage, imageUrl = '', options = {}) {\n const config = getAIConfig();\n if (!config.ai_provider || !config.ai_api_key) throw new Error('AI 客服未配置,请在后台设置中配置 AI 服务商和 API Key');\n const baseUrl = config.ai_base_url || getProviderBaseUrl(config.ai_provider);\n if (!baseUrl) throw new Error('未知的 AI 服务商: ' + config.ai_provider);\n const model = config.ai_model || 'deepseek-chat';\n\n let systemPrompt = config.ai_system_prompt || '你是 imai.work 的智能客服助手,专门解答关于养号、获客、短视频运营的问题。请用中文回答,语气友好专业。如果不确定答案,请诚实说明并建议用户联系人工客服。';\n\n // 记忆 1:用户画像(静态+动态)\n systemPrompt += getUserProfile(options.userId);\n\n // 记忆 2:历史对话记忆\n systemPrompt += getConversationMemory(options.userId);\n\n // 记忆 3:RAG 知识库检索\n const knowledge = await getKnowledgeContext(userMessage);\n if (knowledge) systemPrompt += '\\n\\n' + knowledge;\n\n systemPrompt += '\\n\\n【回复规则】\\n1. 如果知识库中有相关图片,请在回答中用 markdown 图片语法展示。\\n2. 如果知识库内容中带有 📖 教程链接、🔗 外部链接 或 ❓ FAQ链接,你必须在回答末尾原样附上这些链接,不要修改链接地址。\\n3. 回答要简洁实用,避免过多废话。';\n\n const messages = [{ role: 'system', content: systemPrompt }];\n const recentHistory = history.slice(-20);\n for (const msg of recentHistory) messages.push({ role: msg.role, content: msg.content });\n\n if (imageUrl) {\n messages.push({ role: 'user', content: [{ type: 'text', text: userMessage || '请分析这张图片' }, { type: 'image_url', image_url: { url: imageUrl } }] });\n } else {\n messages.push({ role: 'user', content: userMessage });\n }\n\n return callOpenAICompat(baseUrl, config.ai_api_key, model, messages);\n}\n\n/**\n * 获取历史对话记忆(最近 5 条对话的摘要)\n */\nfunction getConversationMemory(userId) {\n if (!userId) return '';\n try {\n const db = getDb();\n const convs = db.prepare(\n \"SELECT summary FROM ai_conversations WHERE user_id = ? AND status = 'closed' AND summary != '' ORDER BY updated_at DESC LIMIT 5\"\n ).all(userId);\n if (convs.length === 0) return '';\n let memory = '\\n\\n【历史记忆】用户之前的对话摘要:\\n';\n for (const c of convs) memory += '- ' + c.summary + '\\n';\n memory += '请参考这些历史信息,避免重复回答已解决的问题。';\n return memory;\n } catch { return ''; }\n}\n\n/**\n * 获取用户画像记忆(静态 + 动态)\n */\nfunction getUserProfile(userId) {\n if (!userId) return '';\n try {\n const db = getDb();\n const parts = [];\n\n // 静态信息:注册时填写的\n const user = db.prepare('SELECT nickname, company, company_role, industry FROM users WHERE id = ?').get(userId);\n if (user) {\n if (user.company) parts.push('公司:' + user.company);\n if (user.company_role) parts.push('职位:' + user.company_role);\n if (user.industry) parts.push('行业:' + user.industry);\n }\n\n // 动态记忆:从对话中学习的\n const memories = db.prepare(\n \"SELECT category, content FROM user_memory WHERE user_id = ? ORDER BY confidence DESC, updated_at DESC LIMIT 15\"\n ).all(userId);\n if (memories.length > 0) {\n const catNames = { interest: '兴趣需求', behavior: '行为特征', context: '业务背景', preference: '偏好习惯', general: '其他' };\n const byCategory = {};\n for (const m of memories) {\n const cat = catNames[m.category] || '其他';\n if (!byCategory[cat]) byCategory[cat] = [];\n byCategory[cat].push(m.content);\n }\n for (const [cat, items] of Object.entries(byCategory)) {\n parts.push(cat + ':' + items.join('、'));\n }\n }\n\n if (parts.length === 0) return '';\n return '\\n\\n【用户画像】' + parts.join(';') + '。请根据用户的具体情况给出针对性回答,不要复述这些信息。';\n } catch { return ''; }\n}\n\n/**\n * 从对话中提取用户特征并保存\n */\nasync function extractUserMemory(userId, conversationId, history) {\n if (!userId || history.length < 2) return;\n const config = getAIConfig();\n if (!config.ai_provider || !config.ai_api_key) return;\n const baseUrl = config.ai_base_url || getProviderBaseUrl(config.ai_provider);\n if (!baseUrl) return;\n const model = config.ai_model || 'deepseek-chat';\n\n const conversationText = history.map(m => (m.role === 'user' ? '用户' : 'AI') + ':' + m.content).join('\\n');\n\n try {\n const result = await callOpenAICompat(baseUrl, config.ai_api_key, model, [\n { role: 'system', content: '你是一个用户画像分析器。从对话中提取用户的特征信息,输出 JSON 数组。\\n\\n每个条目格式:{\"category\": \"分类\", \"content\": \"特征描述\"}\\n分类包括:\\n- interest: 用户的兴趣需求(如\"对抖音养号感兴趣\")\\n- behavior: 用户的行为特征(如\"有多个账号需要管理\")\\n- context: 用户的业务背景(如\"做餐饮行业\"、\"是个体经营者\")\\n- preference: 用户的偏好习惯(如\"喜欢简洁的回答\")\\n\\n规则:\\n1. 只提取明确的信息,不要猜测\\n2. 每条不超过30字\\n3. 最多提取5条\\n4. 如果没有值得记录的特征,输出空数组 []\\n5. 只输出 JSON,不要其他内容' },\n { role: 'user', content: conversationText.slice(-3000) },\n ]);\n\n const jsonMatch = result.match(/\\[.*\\]/s);\n if (!jsonMatch) return;\n const traits = JSON.parse(jsonMatch[0]);\n if (!Array.isArray(traits) || traits.length === 0) return;\n\n const db = getDb();\n const upsert = db.prepare(\n \"INSERT INTO user_memory (user_id, category, content, source_conversation_id) VALUES (?, ?, ?, ?) ON CONFLICT(user_id, content) DO UPDATE SET confidence = confidence + 0.2, updated_at = datetime('now','localtime')\"\n );\n\n let saved = 0;\n for (const trait of traits) {\n if (!trait.content || trait.content.length > 50) continue;\n const validCategories = ['interest', 'behavior', 'context', 'preference', 'general'];\n const cat = validCategories.includes(trait.category) ? trait.category : 'general';\n try { upsert.run(userId, cat, trait.content, conversationId); saved++; } catch {}\n }\n if (saved > 0) console.log('🧠 用户画像更新: 用户' + userId + ' 新增' + saved + '条特征');\n } catch (e) {\n console.warn('用户画像提取失败:', e.message);\n }\n}\n\n/**\n * 生成对话摘要\n */\nasync function generateSummary(history) {\n const config = getAIConfig();\n if (!config.ai_provider || !config.ai_api_key) return '';\n const baseUrl = config.ai_base_url || getProviderBaseUrl(config.ai_provider);\n if (!baseUrl) return '';\n const model = config.ai_model || 'deepseek-chat';\n\n const conversationText = history.map(m => (m.role === 'user' ? '用户' : 'AI') + ':' + m.content).join('\\n');\n\n try {\n const summary = await callOpenAICompat(baseUrl, config.ai_api_key, model, [\n { role: 'system', content: '请用一句话(不超过50字)概括这段对话的核心内容,用于记忆存档。只输出摘要,不要其他内容。' },\n { role: 'user', content: conversationText.slice(-2000) },\n ]);\n return summary.trim().slice(0, 200);\n } catch {\n return '';\n }\n}\n\nmodule.exports = { chat, getAIConfig, generateSummary, extractUserMemory };\n"}, "54c84ea54c3a90233572722cf5e1305947594777": {"t": 1785766724.3817883, "n": 8823, "h": "1afc4fd76b673d90", "c": "/**\n * AI 客服服务 — 调用 LLM API 回答用户问题\n */\nconst https = require('https');\nconst { getDb } = require('../database/schema');\nconst { retrieve: ragRetrieve, rebuildIndex } = require('./rag');\n\n/**\n * 从 settings 获取 AI 配置\n */\nfunction getAIConfig() {\n const db = getDb();\n const rows = db.prepare(\"SELECT key, value FROM settings WHERE key IN ('ai_provider','ai_api_key','ai_model','ai_system_prompt','ai_base_url')\").all();\n const config = {};\n for (const row of rows) config[row.key] = row.value;\n return config;\n}\n\n/**\n * RAG 检索:根据用户问题检索相关知识(支持图片)\n */\nasync function getKnowledgeContext(query) {\n if (!query) return '';\n try {\n const results = await ragRetrieve(query, 5);\n if (results.length === 0) return '';\n let context = '以下是与用户问题最相关的知识库内容,请基于这些信息回答:\\n\\n';\n for (const item of results) {\n context += `【${item.category || '未分类'}】${item.title}\\n${item.content}\\n`;\n // 附带相关图片\n if (item.images && item.images.length > 0) {\n context += `相关图片:${item.images.join(', ')}\\n`;\n }\n context += '\\n';\n }\n return context;\n } catch (e) {\n console.warn('RAG 检索失败:', e.message);\n return '';\n }\n}\n\n/**\n * 调用 OpenAI 兼容 API\n */\nfunction callOpenAICompat(baseUrl, apiKey, model, messages) {\n return new Promise((resolve, reject) => {\n const parsed = new URL(baseUrl);\n const body = JSON.stringify({\n model,\n messages,\n temperature: 0.7,\n max_tokens: 2000,\n });\n const options = {\n hostname: parsed.hostname,\n port: parsed.port || 443,\n path: parsed.pathname.replace(/\\/$/, '') + '/chat/completions',\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Length': Buffer.byteLength(body),\n },\n };\n const req = https.request(options, res => {\n let data = '';\n res.on('data', c => data += c);\n res.on('end', () => {\n try {\n const json = JSON.parse(data);\n if (json.error) return reject(new Error(json.error.message || JSON.stringify(json.error)));\n const content = json.choices?.[0]?.message?.content || '';\n resolve(content);\n } catch (e) {\n reject(new Error('AI 响应解析失败: ' + data.slice(0, 200)));\n }\n });\n });\n req.on('error', reject);\n req.write(body);\n req.end();\n });\n}\n\n/**\n * 获取 provider 的 baseUrl\n */\nfunction getProviderBaseUrl(provider) {\n const urls = {\n deepseek: 'https://api.deepseek.com',\n openai: 'https://api.openai.com/v1',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n };\n return urls[provider] || '';\n}\n\n/**\n * AI 对话主函数(三层记忆:用户画像 + 历史对话 + 全局知识库)\n */\nasync function chat(history, userMessage, imageUrl = '', options = {}) {\n const config = getAIConfig();\n if (!config.ai_provider || !config.ai_api_key) throw new Error('AI 客服未配置,请在后台设置中配置 AI 服务商和 API Key');\n const baseUrl = config.ai_base_url || getProviderBaseUrl(config.ai_provider);\n if (!baseUrl) throw new Error('未知的 AI 服务商: ' + config.ai_provider);\n const model = config.ai_model || 'deepseek-chat';\n\n let systemPrompt = config.ai_system_prompt || '你是 imai.work 的智能客服助手,专门解答关于养号、获客、短视频运营的问题。请用中文回答,语气友好专业。如果不确定答案,请诚实说明并建议用户联系人工客服。';\n\n // 记忆 1:用户画像(静态+动态)\n systemPrompt += getUserProfile(options.userId);\n\n // 记忆 2:历史对话记忆\n systemPrompt += getConversationMemory(options.userId);\n\n // 记忆 3:RAG 知识库检索\n const knowledge = await getKnowledgeContext(userMessage);\n if (knowledge) systemPrompt += '\\n\\n' + knowledge;\n\n systemPrompt += '\\n\\n【回复规则】如果知识库中有相关图片,请在回答中用 markdown 图片语法展示。回答要简洁实用,避免过多废话。';\n\n const messages = [{ role: 'system', content: systemPrompt }];\n const recentHistory = history.slice(-20);\n for (const msg of recentHistory) messages.push({ role: msg.role, content: msg.content });\n\n if (imageUrl) {\n messages.push({ role: 'user', content: [{ type: 'text', text: userMessage || '请分析这张图片' }, { type: 'image_url', image_url: { url: imageUrl } }] });\n } else {\n messages.push({ role: 'user', content: userMessage });\n }\n\n return callOpenAICompat(baseUrl, config.ai_api_key, model, messages);\n}\n\n/**\n * 获取历史对话记忆(最近 5 条对话的摘要)\n */\nfunction getConversationMemory(userId) {\n if (!userId) return '';\n try {\n const db = getDb();\n const convs = db.prepare(\n \"SELECT summary FROM ai_conversations WHERE user_id = ? AND status = 'closed' AND summary != '' ORDER BY updated_at DESC LIMIT 5\"\n ).all(userId);\n if (convs.length === 0) return '';\n let memory = '\\n\\n【历史记忆】用户之前的对话摘要:\\n';\n for (const c of convs) memory += '- ' + c.summary + '\\n';\n memory += '请参考这些历史信息,避免重复回答已解决的问题。';\n return memory;\n } catch { return ''; }\n}\n\n/**\n * 获取用户画像记忆(静态 + 动态)\n */\nfunction getUserProfile(userId) {\n if (!userId) return '';\n try {\n const db = getDb();\n const parts = [];\n\n // 静态信息:注册时填写的\n const user = db.prepare('SELECT nickname, company, company_role, industry FROM users WHERE id = ?').get(userId);\n if (user) {\n if (user.company) parts.push('公司:' + user.company);\n if (user.company_role) parts.push('职位:' + user.company_role);\n if (user.industry) parts.push('行业:' + user.industry);\n }\n\n // 动态记忆:从对话中学习的\n const memories = db.prepare(\n \"SELECT category, content FROM user_memory WHERE user_id = ? ORDER BY confidence DESC, updated_at DESC LIMIT 15\"\n ).all(userId);\n if (memories.length > 0) {\n const catNames = { interest: '兴趣需求', behavior: '行为特征', context: '业务背景', preference: '偏好习惯', general: '其他' };\n const byCategory = {};\n for (const m of memories) {\n const cat = catNames[m.category] || '其他';\n if (!byCategory[cat]) byCategory[cat] = [];\n byCategory[cat].push(m.content);\n }\n for (const [cat, items] of Object.entries(byCategory)) {\n parts.push(cat + ':' + items.join('、'));\n }\n }\n\n if (parts.length === 0) return '';\n return '\\n\\n【用户画像】' + parts.join(';') + '。请根据用户的具体情况给出针对性回答,不要复述这些信息。';\n } catch { return ''; }\n}\n\n/**\n * 从对话中提取用户特征并保存\n */\nasync function extractUserMemory(userId, conversationId, history) {\n if (!userId || history.length < 2) return;\n const config = getAIConfig();\n if (!config.ai_provider || !config.ai_api_key) return;\n const baseUrl = config.ai_base_url || getProviderBaseUrl(config.ai_provider);\n if (!baseUrl) return;\n const model = config.ai_model || 'deepseek-chat';\n\n const conversationText = history.map(m => (m.role === 'user' ? '用户' : 'AI') + ':' + m.content).join('\\n');\n\n try {\n const result = await callOpenAICompat(baseUrl, config.ai_api_key, model, [\n { role: 'system', content: '你是一个用户画像分析器。从对话中提取用户的特征信息,输出 JSON 数组。\\n\\n每个条目格式:{\"category\": \"分类\", \"content\": \"特征描述\"}\\n分类包括:\\n- interest: 用户的兴趣需求(如\"对抖音养号感兴趣\")\\n- behavior: 用户的行为特征(如\"有多个账号需要管理\")\\n- context: 用户的业务背景(如\"做餐饮行业\"、\"是个体经营者\")\\n- preference: 用户的偏好习惯(如\"喜欢简洁的回答\")\\n\\n规则:\\n1. 只提取明确的信息,不要猜测\\n2. 每条不超过30字\\n3. 最多提取5条\\n4. 如果没有值得记录的特征,输出空数组 []\\n5. 只输出 JSON,不要其他内容' },\n { role: 'user', content: conversationText.slice(-3000) },\n ]);\n\n const jsonMatch = result.match(/\\[.*\\]/s);\n if (!jsonMatch) return;\n const traits = JSON.parse(jsonMatch[0]);\n if (!Array.isArray(traits) || traits.length === 0) return;\n\n const db = getDb();\n const upsert = db.prepare(\n \"INSERT INTO user_memory (user_id, category, content, source_conversation_id) VALUES (?, ?, ?, ?) ON CONFLICT(user_id, content) DO UPDATE SET confidence = confidence + 0.2, updated_at = datetime('now','localtime')\"\n );\n\n let saved = 0;\n for (const trait of traits) {\n if (!trait.content || trait.content.length > 50) continue;\n const validCategories = ['interest', 'behavior', 'context', 'preference', 'general'];\n const cat = validCategories.includes(trait.category) ? trait.category : 'general';\n try { upsert.run(userId, cat, trait.content, conversationId); saved++; } catch {}\n }\n if (saved > 0) console.log('🧠 用户画像更新: 用户' + userId + ' 新增' + saved + '条特征');\n } catch (e) {\n console.warn('用户画像提取失败:', e.message);\n }\n}\n\n/**\n * 生成对话摘要\n */\nasync function generateSummary(history) {\n const config = getAIConfig();\n if (!config.ai_provider || !config.ai_api_key) return '';\n const baseUrl = config.ai_base_url || getProviderBaseUrl(config.ai_provider);\n if (!baseUrl) return '';\n const model = config.ai_model || 'deepseek-chat';\n\n const conversationText = history.map(m => (m.role === 'user' ? '用户' : 'AI') + ':' + m.content).join('\\n');\n\n try {\n const summary = await callOpenAICompat(baseUrl, config.ai_api_key, model, [\n { role: 'system', content: '请用一句话(不超过50字)概括这段对话的核心内容,用于记忆存档。只输出摘要,不要其他内容。' },\n { role: 'user', content: conversationText.slice(-2000) },\n ]);\n return summary.trim().slice(0, 200);\n } catch {\n return '';\n }\n}\n\nmodule.exports = { chat, getAIConfig, generateSummary, extractUserMemory };\n"}, "07d9a4cb735c78690d56faa55797451246cb6559": {"t": 1785766725.6166017, "n": 4338, "h": "51d6b60f42418b46", "c": "/**\n * AI 客服服务 — 调用 LLM API 回答用户问题\n */\nconst https = require('https');\nconst { getDb } = require('../database/schema');\nconst { retrieve: ragRetrieve, rebuildIndex } = require('./rag');\n\n/**\n * 从 settings 获取 AI 配置\n */\nfunction getAIConfig() {\n const db = getDb();\n const rows = db.prepare(\"SELECT key, value FROM settings WHERE key IN ('ai_provider','ai_api_key','ai_model','ai_system_prompt','ai_base_url')\").all();\n const config = {};\n for (const row of rows) config[row.key] = row.value;\n return config;\n}\n\n/**\n * RAG 检索:根据用户问题检索相关知识(支持图片)\n */\nasync function getKnowledgeContext(query) {\n if (!query) return '';\n try {\n const results = await ragRetrieve(query, 5);\n if (results.length === 0) return '';\n let context = '以下是与用户问题最相关的知识库内容,请基于这些信息回答:\\n\\n';\n for (const item of results) {\n context += `【${item.category || '未分类'}】${item.title}\\n${item.content}\\n`;\n // 附带相关图片\n if (item.images && item.images.length > 0) {\n context += `相关图片:${item.images.join(', ')}\\n`;\n }\n context += '\\n';\n }\n return context;\n } catch (e) {\n console.warn('RAG 检索失败:', e.message);\n return '';\n }\n}\n\n/**\n * 调用 OpenAI 兼容 API\n */\nfunction callOpenAICompat(baseUrl, apiKey, model, messages) {\n return new Promise((resolve, reject) => {\n const parsed = new URL(baseUrl);\n const body = JSON.stringify({\n model,\n messages,\n temperature: 0.7,\n max_tokens: 2000,\n });\n const options = {\n hostname: parsed.hostname,\n port: parsed.port || 443,\n path: parsed.pathname.replace(/\\/$/, '') + '/chat/completions',\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Length': Buffer.byteLength(body),\n },\n };\n const req = https.request(options, res => {\n let data = '';\n res.on('data', c => data += c);\n res.on('end', () => {\n try {\n const json = JSON.parse(data);\n if (json.error) return reject(new Error(json.error.message || JSON.stringify(json.error)));\n const content = json.choices?.[0]?.message?.content || '';\n resolve(content);\n } catch (e) {\n reject(new Error('AI 响应解析失败: ' + data.slice(0, 200)));\n }\n });\n });\n req.on('error', reject);\n req.write(body);\n req.end();\n });\n}\n\n/**\n * 获取 provider 的 baseUrl\n */\nfunction getProviderBaseUrl(provider) {\n const urls = {\n deepseek: 'https://api.deepseek.com',\n openai: 'https://api.openai.com/v1',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n };\n return urls[provider] || '';\n}\n\n/**\n * AI 对话主函数\n * @param {Array} history - 对话历史 [{role, content}]\n * @param {string} userMessage - 用户新消息\n * @param {string} imageUrl - 用户上传的图片 URL(可选)\n * @returns {string} AI 回复\n */\nasync function chat(history, userMessage, imageUrl = '') {\n const config = getAIConfig();\n\n if (!config.ai_provider || !config.ai_api_key) {\n throw new Error('AI 客服未配置,请在后台设置中配置 AI 服务商和 API Key');\n }\n\n const baseUrl = config.ai_base_url || getProviderBaseUrl(config.ai_provider);\n if (!baseUrl) throw new Error('未知的 AI 服务商: ' + config.ai_provider);\n\n const model = config.ai_model || 'deepseek-chat';\n\n // 构建 system prompt\n let systemPrompt = config.ai_system_prompt || '你是 imai.work 的智能客服助手,专门解答关于养号、获客、短视频运营的问题。请用中文回答,语气友好专业。如果不确定答案,请诚实说明并建议用户联系人工客服。';\n\n // RAG 检索相关知识\n const knowledge = await getKnowledgeContext(userMessage);\n if (knowledge) {\n systemPrompt += '\\n\\n' + knowledge;\n }\n\n // 告诉 LLM 可以输出图片\n systemPrompt += '\\n\\n【回复规则】如果知识库中有相关图片,请在回答中用 markdown 图片语法展示:![描述](图片URL)。图片能让用户更直观地理解。';\n\n // 构建消息列表\n const messages = [{ role: 'system', content: systemPrompt }];\n\n // 加入对话历史(最近 10 轮)\n const recentHistory = history.slice(-20);\n for (const msg of recentHistory) {\n messages.push({ role: msg.role, content: msg.content });\n }\n\n // 加入用户新消息(支持图片)\n if (imageUrl) {\n messages.push({\n role: 'user',\n content: [\n { type: 'text', text: userMessage || '请分析这张图片' },\n { type: 'image_url', image_url: { url: imageUrl } },\n ],\n });\n } else {\n messages.push({ role: 'user', content: userMessage });\n }\n\n const reply = await callOpenAICompat(baseUrl, config.ai_api_key, model, messages);\n return reply;\n}\n\nmodule.exports = { chat, getAIConfig };\n"}, "3434aeeecff5dee82ac201362a02222b99c273e2": {"t": 1785766727.0413268, "n": 4320, "h": "f6a7fee5e56789f7", "c": "/**\n * AI 客服服务 — 调用 LLM API 回答用户问题\n */\nconst https = require('https');\nconst { getDb } = require('../database/schema');\nconst { retrieve: ragRetrieve, rebuildIndex } = require('./rag');\n\n/**\n * 从 settings 获取 AI 配置\n */\nfunction getAIConfig() {\n const db = getDb();\n const rows = db.prepare(\"SELECT key, value FROM settings WHERE key IN ('ai_provider','ai_api_key','ai_model','ai_system_prompt','ai_base_url')\").all();\n const config = {};\n for (const row of rows) config[row.key] = row.value;\n return config;\n}\n\n/**\n * RAG 检索:根据用户问题检索相关知识(支持图片)\n */\nfunction getKnowledgeContext(query) {\n if (!query) return '';\n try {\n const results = ragRetrieve(query, 5);\n if (results.length === 0) return '';\n let context = '以下是与用户问题最相关的知识库内容,请基于这些信息回答:\\n\\n';\n for (const item of results) {\n context += `【${item.category || '未分类'}】${item.title}\\n${item.content}\\n`;\n // 附带相关图片\n if (item.images && item.images.length > 0) {\n context += `相关图片:${item.images.join(', ')}\\n`;\n }\n context += '\\n';\n }\n return context;\n } catch (e) {\n console.warn('RAG 检索失败:', e.message);\n return '';\n }\n}\n\n/**\n * 调用 OpenAI 兼容 API\n */\nfunction callOpenAICompat(baseUrl, apiKey, model, messages) {\n return new Promise((resolve, reject) => {\n const parsed = new URL(baseUrl);\n const body = JSON.stringify({\n model,\n messages,\n temperature: 0.7,\n max_tokens: 2000,\n });\n const options = {\n hostname: parsed.hostname,\n port: parsed.port || 443,\n path: parsed.pathname.replace(/\\/$/, '') + '/chat/completions',\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Length': Buffer.byteLength(body),\n },\n };\n const req = https.request(options, res => {\n let data = '';\n res.on('data', c => data += c);\n res.on('end', () => {\n try {\n const json = JSON.parse(data);\n if (json.error) return reject(new Error(json.error.message || JSON.stringify(json.error)));\n const content = json.choices?.[0]?.message?.content || '';\n resolve(content);\n } catch (e) {\n reject(new Error('AI 响应解析失败: ' + data.slice(0, 200)));\n }\n });\n });\n req.on('error', reject);\n req.write(body);\n req.end();\n });\n}\n\n/**\n * 获取 provider 的 baseUrl\n */\nfunction getProviderBaseUrl(provider) {\n const urls = {\n deepseek: 'https://api.deepseek.com',\n openai: 'https://api.openai.com/v1',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n };\n return urls[provider] || '';\n}\n\n/**\n * AI 对话主函数\n * @param {Array} history - 对话历史 [{role, content}]\n * @param {string} userMessage - 用户新消息\n * @param {string} imageUrl - 用户上传的图片 URL(可选)\n * @returns {string} AI 回复\n */\nasync function chat(history, userMessage, imageUrl = '') {\n const config = getAIConfig();\n\n if (!config.ai_provider || !config.ai_api_key) {\n throw new Error('AI 客服未配置,请在后台设置中配置 AI 服务商和 API Key');\n }\n\n const baseUrl = config.ai_base_url || getProviderBaseUrl(config.ai_provider);\n if (!baseUrl) throw new Error('未知的 AI 服务商: ' + config.ai_provider);\n\n const model = config.ai_model || 'deepseek-chat';\n\n // 构建 system prompt\n let systemPrompt = config.ai_system_prompt || '你是 imai.work 的智能客服助手,专门解答关于养号、获客、短视频运营的问题。请用中文回答,语气友好专业。如果不确定答案,请诚实说明并建议用户联系人工客服。';\n\n // RAG 检索相关知识\n const knowledge = getKnowledgeContext(userMessage);\n if (knowledge) {\n systemPrompt += '\\n\\n' + knowledge;\n }\n\n // 告诉 LLM 可以输出图片\n systemPrompt += '\\n\\n【回复规则】如果知识库中有相关图片,请在回答中用 markdown 图片语法展示:![描述](图片URL)。图片能让用户更直观地理解。';\n\n // 构建消息列表\n const messages = [{ role: 'system', content: systemPrompt }];\n\n // 加入对话历史(最近 10 轮)\n const recentHistory = history.slice(-20);\n for (const msg of recentHistory) {\n messages.push({ role: msg.role, content: msg.content });\n }\n\n // 加入用户新消息(支持图片)\n if (imageUrl) {\n messages.push({\n role: 'user',\n content: [\n { type: 'text', text: userMessage || '请分析这张图片' },\n { type: 'image_url', image_url: { url: imageUrl } },\n ],\n });\n } else {\n messages.push({ role: 'user', content: userMessage });\n }\n\n const reply = await callOpenAICompat(baseUrl, config.ai_api_key, model, messages);\n return reply;\n}\n\nmodule.exports = { chat, getAIConfig };\n"}, "f882ddb355bf9ea7f8eff6c1d5cd683e145d0aec": {"t": 1785766728.2832396, "n": 4050, "h": "b560c8f976527d4e", "c": "/**\n * AI 客服服务 — 调用 LLM API 回答用户问题\n */\nconst https = require('https');\nconst { getDb } = require('../database/schema');\nconst { retrieve: ragRetrieve, rebuildIndex } = require('./rag');\n\n/**\n * 从 settings 获取 AI 配置\n */\nfunction getAIConfig() {\n const db = getDb();\n const rows = db.prepare(\"SELECT key, value FROM settings WHERE key IN ('ai_provider','ai_api_key','ai_model','ai_system_prompt','ai_base_url')\").all();\n const config = {};\n for (const row of rows) config[row.key] = row.value;\n return config;\n}\n\n/**\n * RAG 检索:根据用户问题检索相关知识\n */\nfunction getKnowledgeContext(query) {\n if (!query) return '';\n try {\n const results = ragRetrieve(query, 5);\n if (results.length === 0) return '';\n let context = '以下是与用户问题最相关的知识库内容,请基于这些信息回答:\\n\\n';\n for (const item of results) {\n context += `【${item.category || '未分类'}】${item.title}\\n${item.content}\\n\\n`;\n }\n return context;\n } catch (e) {\n console.warn('RAG 检索失败:', e.message);\n return '';\n }\n}\n\n/**\n * 调用 OpenAI 兼容 API\n */\nfunction callOpenAICompat(baseUrl, apiKey, model, messages) {\n return new Promise((resolve, reject) => {\n const parsed = new URL(baseUrl);\n const body = JSON.stringify({\n model,\n messages,\n temperature: 0.7,\n max_tokens: 2000,\n });\n const options = {\n hostname: parsed.hostname,\n port: parsed.port || 443,\n path: parsed.pathname.replace(/\\/$/, '') + '/chat/completions',\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Length': Buffer.byteLength(body),\n },\n };\n const req = https.request(options, res => {\n let data = '';\n res.on('data', c => data += c);\n res.on('end', () => {\n try {\n const json = JSON.parse(data);\n if (json.error) return reject(new Error(json.error.message || JSON.stringify(json.error)));\n const content = json.choices?.[0]?.message?.content || '';\n resolve(content);\n } catch (e) {\n reject(new Error('AI 响应解析失败: ' + data.slice(0, 200)));\n }\n });\n });\n req.on('error', reject);\n req.write(body);\n req.end();\n });\n}\n\n/**\n * 获取 provider 的 baseUrl\n */\nfunction getProviderBaseUrl(provider) {\n const urls = {\n deepseek: 'https://api.deepseek.com',\n openai: 'https://api.openai.com/v1',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n };\n return urls[provider] || '';\n}\n\n/**\n * AI 对话主函数\n * @param {Array} history - 对话历史 [{role, content}]\n * @param {string} userMessage - 用户新消息\n * @param {string} imageUrl - 用户上传的图片 URL(可选)\n * @returns {string} AI 回复\n */\nasync function chat(history, userMessage, imageUrl = '') {\n const config = getAIConfig();\n\n if (!config.ai_provider || !config.ai_api_key) {\n throw new Error('AI 客服未配置,请在后台设置中配置 AI 服务商和 API Key');\n }\n\n const baseUrl = config.ai_base_url || getProviderBaseUrl(config.ai_provider);\n if (!baseUrl) throw new Error('未知的 AI 服务商: ' + config.ai_provider);\n\n const model = config.ai_model || 'deepseek-chat';\n\n // 构建 system prompt\n let systemPrompt = config.ai_system_prompt || '你是 imai.work 的智能客服助手,专门解答关于养号、获客、短视频运营的问题。请用中文回答,语气友好专业。如果不确定答案,请诚实说明并建议用户联系人工客服。';\n\n // RAG 检索相关知识\n const knowledge = getKnowledgeContext(userMessage);\n if (knowledge) {\n systemPrompt += '\\n\\n' + knowledge;\n }\n\n // 构建消息列表\n const messages = [{ role: 'system', content: systemPrompt }];\n\n // 加入对话历史(最近 10 轮)\n const recentHistory = history.slice(-20);\n for (const msg of recentHistory) {\n messages.push({ role: msg.role, content: msg.content });\n }\n\n // 加入用户新消息(支持图片)\n if (imageUrl) {\n messages.push({\n role: 'user',\n content: [\n { type: 'text', text: userMessage || '请分析这张图片' },\n { type: 'image_url', image_url: { url: imageUrl } },\n ],\n });\n } else {\n messages.push({ role: 'user', content: userMessage });\n }\n\n const reply = await callOpenAICompat(baseUrl, config.ai_api_key, model, messages);\n return reply;\n}\n\nmodule.exports = { chat, getAIConfig };\n"}}}}