{"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} 嵌入向量\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} 嵌入向量数组(失败的用 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} 索引条目数\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} [{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(/]+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} 嵌入向量\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} 嵌入向量数组(失败的用 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} 索引条目数\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} [{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(/]+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} 嵌入向量\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} 嵌入向量数组(失败的用 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} 索引条目数\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} [{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(/]+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} 嵌入向量\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} 嵌入向量数组(失败的用 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} 索引条目数\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} [{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(/]+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: \n const htmlImgs = content.match(/]+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"}}}}