如果你想把这篇文章和源码对着看,可以先留意文中出现的几个文件名。这个系列会继续按源码往后拆;如果这种拆解方式对你有帮助。
用户问客服问题时,不一定会使用知识库原文里的词。
知识库里可能写的是:
TEXT
七天无理由退换货政策用户可能问的是:
TEXT
我买了以后不喜欢还能退吗?这两个句子字面上不一样,但意思很接近。
传统关键词搜索可能搜不到,或者搜得不准。
向量数据库解决的就是这个问题:
TEXT
不只按字面匹配,而是按语义相似度搜索。本项目使用的向量数据库是 Qdrant。
一句话理解向量数据库
可以先这样理解:
TEXT
向量数据库是专门用来保存和搜索“向量”的数据库。那什么是向量?
在 AI 检索场景里,可以把向量理解成:
TEXT
一段文本的数字化坐标。比如:
TEXT
“退货政策” -> [0.12, -0.03, 0.88, ...]“不喜欢还能退吗” -> [0.10, -0.01, 0.84, ...]“蓝牙耳机库存” -> [-0.42, 0.31, 0.09, ...]如果两段话意思接近,它们的向量距离就更近。
所以向量数据库可以做这种搜索:
TEXT
给我一句用户问题,找出知识库里语义最接近的几段内容。Qdrant 在项目里的位置
和 Qdrant / 向量数据库相关的文件主要有:
docker-compose.yml | |
server/src/services/vectorService.js | |
server/src/services/knowledgeService.js | |
server/src/services/adminService.js | |
server/src/services/customerServiceAgent.js | retrieveKnowledge 节点里调用知识库搜索 |
server/src/services/deepseekService.js |
整体链路可以画成这样:
Mermaid 流程图静态示意
管理员维护知识/商品
↓
adminService.js
AdminService
↓
splitMarkdown() / 按标题切片
Split
↓
knowledge_chunks / MySQL 保存切片记录
Split
↓
vectorService.upsertChunks()
Vector
↓
Qdrant / 保存向量 + payload
用户提问
↓
customerServiceAgent
Agent
↓
knowledgeService.search()
Knowledge
↓
vectorService.search()
VectorSearch
↓
Qdrant
Qdrant
↓
相似知识片段
Results
↓
deepseekService.chat() / 拼进 Prompt
Prompt
↓
AI 客服回复
这里分成两条链路:
TEXT
发布链路:知识写入 Qdrant检索链路:用户提问时从 Qdrant 找知识Qdrant 是怎么启动的
项目用 Docker Compose 启动 Qdrant。
位置:
TEXT
docker-compose.yml配置里有:
YAML
qdrant: image: qdrant/qdrant:latest container_name: kf-qdrant ports: - "${QDRANT_HTTP_PORT:-6333}:6333" - "${QDRANT_GRPC_PORT:-6334}:6334" volumes: - qdrant_data:/qdrant/storage也就是说,Qdrant 会作为一个独立服务启动。
后端通过 HTTP 访问它,默认地址是:
TEXT
http://qdrant:6333本地开发时也可以是:
TEXT
http://localhost:6333相关环境变量在 server/.env.example 里:
TEXT
VECTOR_DB_PROVIDER=qdrantVECTOR_DB_URL=http://localhost:6333VECTOR_COLLECTION=customer_service_knowledgeVECTOR_DIMENSION=384VECTOR_TOP_K=4VECTOR_MAX_CHARS=1200VECTOR_TIMEOUT_MS=3000VECTOR_SEARCH_ENABLED=true这些变量决定:
是否启用向量检索
Qdrant 地址是什么
集合叫什么
向量维度是多少
每次最多取几条结果
最多给大模型多少知识文本
Collection:向量数据库里的“表”
在 Qdrant 里,Collection 可以先理解成 MySQL 里的表。
本项目的 Collection 名叫:
TEXT
customer_service_knowledge配置在:
JS
const VECTOR_COLLECTION = process.env.VECTOR_COLLECTION || 'customer_service_knowledge';创建 Collection 的代码在:
TEXT
server/src/services/vectorService.js核心方法:
JS
async function ensureCollection() { const existing = await request(`/collections/${VECTOR_COLLECTION}`, { allow404: true, }); if (existing) { return; } await request(`/collections/${VECTOR_COLLECTION}`, { method: 'PUT', body: { vectors: { size: VECTOR_DIMENSION, distance: 'Cosine', }, }, });}这里有两个关键词:
TEXT
size: VECTOR_DIMENSIONdistance: Cosinesize 表示每条向量有多少维。
项目默认是:
TEXT
384Cosine 表示用余弦相似度来计算两段文本的相似程度。
Point:Qdrant 里真正保存的一条知识
Qdrant 里保存的数据通常叫 Point。
一个 Point 大概包含:
TEXT
idvectorpayload在项目里,写入 Qdrant 的代码是:
JS
const points = chunks.map((chunk) => ({ id: pointIdFor(chunk), vector: embedText(chunk.content), payload: { id: chunk.id, document_id: chunk.document_id || null, title: chunk.title || null, source: chunk.source || null, content: chunk.content, type: chunk.type || 'knowledge', },}));这段代码说明:
id:这条知识点的唯一标识vector:知识内容转成的向量payload:原始业务信息,例如标题、正文、文档 id
为什么还要 payload?
因为向量只适合用来算相似度,不适合直接展示。
真正给大模型看的,还是 payload 里的:
TEXT
titlesourcecontent这个项目里的 embedText 是什么
严格来说,生产级向量检索通常会使用 embedding 模型,例如把文本送到专门的 embedding API,得到高质量语义向量。
但这个项目当前的 embedText() 是一个轻量实现。
代码位置:
TEXT
server/src/services/vectorService.js它会先把文本切成 token:
JS
function tokenize(text) { const normalized = String(text || '').toLowerCase(); const words = normalized.match(/[a-z0-9]+/g) || []; const chars = Array.from(normalized).filter((char) => /\S/u.test(char)); const grams = []; for (let i = 0; i < chars.length; i += 1) { grams.push(chars[i]); if (i + 1 < chars.length) { grams.push(chars[i] + chars[i + 1]); } } return [...words, ...grams];}然后把 token 哈希到固定维度的数组里:
JS
function embedText(text) { const vector = new Array(VECTOR_DIMENSION).fill(0); const tokens = tokenize(text); for (const token of tokens) { const hash = hashToInt(token); const index = hash % VECTOR_DIMENSION; const sign = hash % 2 === 0 ? 1 : -1; vector[index] += sign; } const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0)); return vector.map((value) => value / norm);}这不是最强的语义 embedding,但它有几个优点:
不依赖额外 embedding 服务
本地就能跑
能做一个轻量的相似度检索演示
适合教学理解向量检索流程
所以这篇文章可以先把它理解成:
TEXT
项目用 hash 向量模拟 embedding,把文本变成 384 维数字数组,再交给 Qdrant 搜索。知识是怎么发布到 Qdrant 的
知识发布主要在:
TEXT
server/src/services/adminService.js管理员可以维护知识文档,发布时会调用:
JS
async function publishKnowledge(id) { const document = await getKnowledge(id); if (!document) throw new Error('知识不存在'); const chunks = splitMarkdown(document); await vectorService.deleteByDocumentId(document.id); const result = await vectorService.upsertChunks(chunks); await pool.execute('DELETE FROM knowledge_chunks WHERE document_id = ?', [document.id]); // 保存切片记录到 MySQL await pool.execute( `UPDATE knowledge_documents SET status = 'published', published_at = NOW() WHERE id = ?`, [document.id] ); return { document: await getKnowledge(document.id), chunks: result.points.length, };}这个流程可以拆成 5 步:
TEXT
1. 从 MySQL 读取知识文档2. splitMarkdown() 把 Markdown 切成多个片段3. 删除旧的向量4. upsertChunks() 把新片段写入 Qdrant5. 把切片记录写入 MySQL 的 knowledge_chunks 表为什么要切片?
因为一篇知识文档可能很长,用户的问题通常只需要其中一小段。
例如一篇文档:
MARKDOWN
# 售后政策## 七天无理由签收后 7 天内,商品不影响二次销售可申请退货。## 运费说明质量问题由商家承担运费,非质量问题由用户承担。## 退款时效仓库验收后 1-3 个工作日原路退回。如果用户问:
TEXT
退款多久能到账?最好只检索出:
TEXT
退款时效:仓库验收后 1-3 个工作日原路退回。而不是把整篇售后政策都塞给大模型。
这就是切片的意义。
项目里的切片逻辑是按 Markdown 标题切:
JS
const heading = line.match(/^(#{1,3})\s+(.+)$/);if (heading) { flush(); title = heading[2].trim(); continue;}也就是说,#、##、### 标题会成为切片边界。
商品也可以发布成知识
项目里还有一个很实用的设计:
TEXT
商品信息可以自动转换成 Markdown 知识。代码在:
JS
function productToMarkdown(product) { return [ `# ${product.name}`, '', `SKU: ${product.sku || `SKU-${product.id}`}`, `分类: ${product.category || '未分类'}`, `状态: ${product.status === 'active' ? '上架' : '下架'}`, `价格: ${product.price}`, `库存: ${product.stock}`, '', '## 商品说明', product.description || '暂无商品说明。', '', '注意:库存、价格和订单状态必须以 MySQL 业务查询结果为准。', ].join('\n');}例如后台有一个商品:
JSON
{ "name": "蓝牙耳机 Pro", "sku": "BT-PRO-01", "category": "数码配件", "price": 199, "stock": 36, "description": "主动降噪,30 小时续航,适合通勤和运动。"}它会变成类似:
MARKDOWN
# 蓝牙耳机 ProSKU: BT-PRO-01分类: 数码配件状态: 上架价格: 199库存: 36## 商品说明主动降噪,30 小时续航,适合通勤和运动。注意:库存、价格和订单状态必须以 MySQL 业务查询结果为准。发布后,这些内容会被切片、向量化、写入 Qdrant。
这样用户问商品相关问题时,知识库可以补充商品说明。
不过项目也特别强调:
TEXT
库存、价格和订单状态必须以 MySQL 业务查询结果为准。这是为了避免知识库内容过期。
用户提问时,怎么从 Qdrant 搜索知识
聊天流程里,知识检索发生在 LangGraph 的 retrieveKnowledge 节点:
JS
const retrievedKnowledge = await knowledgeService.search( state.intentResult.retrievalQuery || state.message, { intent: state.intentResult.intent, });knowledgeService.search() 会先判断当前意图是否适合检索:
JS
const SEARCHABLE_INTENTS = new Set([ 'PRODUCT_INQUIRY', 'GENERAL_QA', 'UNKNOWN', 'COMPLAINT',]);function shouldSearch(intent) { return SEARCHABLE_INTENTS.has(intent);}如果是商品咨询、通用问答、未知问题、投诉,就查知识库。
如果是订单查询、取消订单、退款,则更依赖 MySQL 业务数据。
真正查 Qdrant 的代码在 vectorService.search():
JS
const result = await request(`/collections/${VECTOR_COLLECTION}/points/search`, { method: 'POST', body: { vector: embedText(query), limit, with_payload: true, },});这段代码做了三件事:
TEXT
1. 把用户问题 embedText(query) 转成向量2. 去 Qdrant 里找最相似的 points3. 把 payload 里的 title/content/source 返回返回结果会被整理成:
JS
return (result.result || []).map((item) => ({ score: item.score, ...(item.payload || {}),}));检索结果怎么交给大模型
Qdrant 搜到的结果不会直接返回给用户。
它会先进入 Prompt。
在 deepseekService.js 里,有一个格式化方法:
JS
function formatRetrievedKnowledge(retrievedKnowledge) { if (!Array.isArray(retrievedKnowledge) || retrievedKnowledge.length === 0) { return '无'; } return retrievedKnowledge .map((item, index) => { const title = item.title || '未命名知识'; const source = item.source || 'unknown'; return `[${index + 1}] ${title} (${source})\n${item.content}`; }) .join('\n\n');}然后传进回复生成链:
JS
retrieved_knowledge: formatRetrievedKnowledge(retrievedKnowledge),Prompt 里还写了规则:
TEXT
知识库参考只用于补充 FAQ、政策、话术、商品说明;如果与业务数据冲突,以业务数据为准。如果知识库参考为空,不要声称查到了知识库内容。这就是 RAG 的基本思路:
TEXT
先检索相关资料,再让大模型基于资料回答。RAG 全称是 Retrieval-Augmented Generation,中文常说“检索增强生成”。
在本项目中:
TEXT
Qdrant 负责 Retrieval,DeepSeek 负责 Generation。示例一:用户问退货政策
假设知识库里有一篇已发布文档:
MARKDOWN
# 售后政策## 七天无理由签收后 7 天内,商品不影响二次销售可申请退货。## 退款时效仓库验收通过后,1-3 个工作日原路退回。用户问:
TEXT
我买了不喜欢还能退吗?流程是:
TEXT
intentService -> 识别为 GENERAL_QA 或 COMPLAINT/UNKNOWNknowledgeService -> 允许检索vectorService.search -> 把问题转向量Qdrant -> 找到“七天无理由”片段deepseekService.chat -> 把片段放进 PromptAI -> 生成自然回复最终回复可能是:
TEXT
可以的。根据售后政策,签收后 7 天内,如果商品不影响二次销售,可以申请七天无理由退货。如果您已经下单,也可以把订单号发我,我帮您继续看具体状态。这里的“签收后 7 天内、不影响二次销售”来自知识库,不是模型凭空编的。
示例二:用户问商品卖点和库存
用户问:
TEXT
蓝牙耳机 Pro 还有货吗?适合通勤吗?这个问题有两类信息:
TEXT
库存:应该查 MySQL适合通勤吗:可以参考知识库商品说明项目流程大概是:
TEXT
runBusinessAction -> queryProduct() 查 MySQL,拿到库存和价格retrieveKnowledge -> Qdrant 搜“蓝牙耳机 Pro 通勤”generateReply -> 把业务数据和知识库一起给大模型回复应该像这样:
TEXT
有货的。蓝牙耳机 Pro 当前库存是 36 件,价格是 199 元。它支持主动降噪和 30 小时续航,比较适合通勤场景。注意这里的优先级:
TEXT
库存和价格以 MySQL 为准;卖点说明可以用知识库补充。这就是项目 Prompt 里反复强调业务数据优先的原因。
为什么知识库检索失败不直接报错
在 knowledgeService.search() 里可以看到:
JS
try { const results = await vectorService.search(query, { limit }); return trimResults(results, maxChars);} catch (err) { console.warn('[knowledgeService] vector search skipped:', err.message); return [];}如果 Qdrant 暂时不可用,项目不会让整个聊天接口失败,而是返回空知识:
TEXT
[]这是一种降级设计。
原因是:
TEXT
知识库是增强能力,不是所有对话的唯一生命线。比如用户只是问:
TEXT
我的订单在哪里?系统主要依赖 MySQL 订单数据,不一定需要 Qdrant。
如果因为知识库检索失败就让聊天整体报错,用户体验会很差。
Qdrant 和 MySQL 的区别
这个项目同时用了 MySQL 和 Qdrant。
它们不是互相替代,而是各自负责不同问题。
简单说:
TEXT
MySQL 适合问“确定值”。Qdrant 适合问“相关资料”。例如:
TEXT
订单 1001 当前状态是什么?应该查 MySQL。
TEXT
退款一般多久能到账?可以查 Qdrant 里的售后政策。
TEXT
这款耳机适合运动吗?可以先查 MySQL 得到商品,再查 Qdrant 补充商品说明。
最后用一句话记住
在这个 AI 客服项目里:
TEXT
Qdrant 是知识库的语义检索引擎,向量数据库让系统可以按“意思相近”找到相关知识,再把这些知识交给大模型生成更可靠的回答。如果你读源码,建议按这个顺序:
TEXT
docker-compose.yml-> server/src/services/vectorService.js-> server/src/services/adminService.js-> server/src/services/knowledgeService.js-> server/src/services/customerServiceAgent.js-> server/src/services/deepseekService.js读完这条链,你就会明白:
TEXT
知识库不是直接替客服回答,而是先被切片、向量化、写入 Qdrant,用户提问时再被检索出来,作为大模型回答的参考材料。
夜雨聆风