feat: 知识库查询按照意图分类查询
This commit is contained in:
parent
430141b550
commit
23ec184ab0
0
app/chat_qa/classification/__init__.py
Normal file
0
app/chat_qa/classification/__init__.py
Normal file
213
app/chat_qa/classification/intent_classification_qa.py
Normal file
213
app/chat_qa/classification/intent_classification_qa.py
Normal file
File diff suppressed because one or more lines are too long
113
app/chat_qa/classification/intent_test.py
Normal file
113
app/chat_qa/classification/intent_test.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
from app.repository.milvus.intent_classification_repository import IntentClassificationRepository
|
||||||
|
from app.client.embedding_client_manager import embedding_client
|
||||||
|
|
||||||
|
|
||||||
|
INTENT_DATA = {
|
||||||
|
"课程内容": [
|
||||||
|
"课程针对零基础,涵盖动作设计、场景建模等方向,推荐游戏动画方向,并建议考虑场景建模以规避美术基础劣势。",
|
||||||
|
"UE5相关有3门课(场景建模地编、UE特效、UE开发),但'人物动起来'属于动作课,不使用虚幻,用3dmax/Maya,仅线下班,周期6个月,学费31800元。"
|
||||||
|
],
|
||||||
|
"其他": [
|
||||||
|
"课程覆盖游戏设计全流程,特效线上课8-10个月,每周2-3次直播(晚7:30-9:30),提供录播和答疑,费用11800元。特效行业需求大,薪资6000-9000元起,需自身具备制作能力,AI是工具而非替代,可推荐就业。",
|
||||||
|
"UE5相关有3门课(场景建模地编、UE特效、UE开发),但'人物动起来'属于动作课,不使用虚幻,用3dmax/Maya,仅线下班,周期6个月,学费31800元。"
|
||||||
|
],
|
||||||
|
"就业支持": [
|
||||||
|
"课程周期8-10个月,当前班级(5月14日开课)预计12月结业。内推渠道包括公司自留岗位和合作企业,就业老师会按学历、技术、城市、薪资匹配,测试流程3-5个工作日,并提供面试辅导。",
|
||||||
|
"有专门就业老师负责对接,提供内推服务。"
|
||||||
|
],
|
||||||
|
"开班时间": [
|
||||||
|
"销售邀请用户于6月1日前入班学习,新班将于6.1开课,用户可据此安排时间。",
|
||||||
|
"人满即止,七月份班已有两三人。"
|
||||||
|
],
|
||||||
|
"费用咨询": [
|
||||||
|
"第九联盟(点晴科技)提供线上课程11800元/8-10个月和线下课程33800元/6个月全日制,大学生可线上+寒暑假线下结合(按线下收费)。支持包括3年回放和答疑、重读机会,以及简历辅导、模拟面试、测试指导、校招群内推等服务。",
|
||||||
|
"课程方案有:31800元/7个月全日制;11800元/8-10个月。"
|
||||||
|
],
|
||||||
|
"校区地址": [
|
||||||
|
"西安有分校区授课;上海总部授课;武汉校区仅做项目(不授课),并非关闭;青岛、厦门、合肥、漯河分公司也仅做项目。",
|
||||||
|
"机构名称为第九联盟,地址在漕河泾,是点晴科技旗下品牌,主营游戏动漫美术人才孵化。"
|
||||||
|
],
|
||||||
|
"退款政策": ['原画对新人要求高,需原创和设计能力,建议造型能力强者考虑角色建模。退款政策:7天无理由全额退款,8-30天退一半,超过30天不退,可休学。', '7天无理由退全款,30天内退一半,之后不退。', '5月27日起7天内可无理由全额退款。'],
|
||||||
|
"优惠活动": [
|
||||||
|
"是的,如果尾款不连续交满6个月则没有优惠,需补尾款19800元(原价31800元减去已付12000元)。",
|
||||||
|
"报名线下班会额外赠送一套线上正式课程,线上课程可让学生在校提前打基础,寒暑假可集中线下学习直接入班。该课程也可以结合线上内容赶时间进度。"
|
||||||
|
],
|
||||||
|
"报名流程": [
|
||||||
|
"两种模式:先线上后补差价转线下,或直接全款预留线下位置。线上课程8-10个月,学费11800元,含直播(每周三次,19:30-21:30)、录播、老师答疑及就业辅导。",
|
||||||
|
"需要填写姓名、学历、学校、城市、出生日期、学习目的、联系方式等信息。"
|
||||||
|
],
|
||||||
|
"报到时间": [
|
||||||
|
"建议与家人确定后尽早来。"
|
||||||
|
],
|
||||||
|
"住宿咨询": [
|
||||||
|
"合作宿舍约1000元/月,4/6人间,按空房分配。",
|
||||||
|
"不需要,上海有宿舍可选,分4人间和6人间,费用约1000元/月;也可以自行租房,单间费用约2000-3000元/月。"
|
||||||
|
],
|
||||||
|
"上课方式": ['可寒暑假线下累积学习(7-8个月),在校期间免费开通线上班辅助', '可寒暑假线下累积学习(7-8个月),在校期间免费开通线上班辅助'],
|
||||||
|
"职业方向": [
|
||||||
|
"动画特效岗适合基础;地编需综合能力,可从模型岗转;TA难度大需美术+代码。课程为6个月线下周一至周五上课,就业方向以游戏行业为主,有学生入职腾讯、网易、米哈游的案例。"
|
||||||
|
],
|
||||||
|
"课程建议": [
|
||||||
|
"动画特效岗适合基础;地编需综合能力,可从模型岗转;TA难度大需美术+代码。课程为6个月线下周一至周五上课,就业方向以游戏行业为主,有学生入职腾讯、网易、米哈游的案例。"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def insert_intent_data():
|
||||||
|
"""
|
||||||
|
将意图分类数据入库到Milvus
|
||||||
|
"""
|
||||||
|
repo = IntentClassificationRepository()
|
||||||
|
|
||||||
|
repo.create_collection()
|
||||||
|
|
||||||
|
all_data = []
|
||||||
|
all_contents = []
|
||||||
|
|
||||||
|
for category, contents in INTENT_DATA.items():
|
||||||
|
for content in contents:
|
||||||
|
all_data.append({
|
||||||
|
"intent_category": category,
|
||||||
|
"content": content
|
||||||
|
})
|
||||||
|
all_contents.append(content)
|
||||||
|
|
||||||
|
print(f"开始向量化 {len(all_contents)} 条内容...")
|
||||||
|
vectors = await embedding_client.aembed_documents(all_contents)
|
||||||
|
print(f"向量化完成")
|
||||||
|
|
||||||
|
for i, item in enumerate(all_data):
|
||||||
|
item["content_vector"] = vectors[i]
|
||||||
|
|
||||||
|
ids = await repo.save_intent_data(all_data)
|
||||||
|
print(f"入库成功,插入 {len(ids)} 条记录")
|
||||||
|
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
async def query_by_category(category: str):
|
||||||
|
"""
|
||||||
|
根据意图类别查询
|
||||||
|
"""
|
||||||
|
repo = IntentClassificationRepository()
|
||||||
|
results = await repo.search_by_intent_category(category)
|
||||||
|
print(f"\n查询 '{category}' 结果:")
|
||||||
|
for i, item in enumerate(results):
|
||||||
|
print(f" {i+1}. {item.get('content', '')[:50]}...")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("=== 意图分类数据入库 ===")
|
||||||
|
await insert_intent_data()
|
||||||
|
|
||||||
|
# print("\n=== 查询测试 ===")
|
||||||
|
# await query_by_category("课程内容")
|
||||||
|
# await query_by_category("就业支持")
|
||||||
|
# await query_by_category("费用咨询")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@ -73,5 +73,5 @@ async def save_qa_milvus(file_path:str):
|
|||||||
milvus_client.close()
|
milvus_client.close()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
file_path=r"E:\qa_output\data.txt"
|
file_path=r"E:\qa_output\test.txt"
|
||||||
asyncio.run(save_qa_milvus(file_path))
|
asyncio.run(save_qa_milvus(file_path))
|
||||||
@ -45,13 +45,13 @@ if __name__ == '__main__':
|
|||||||
archive_messages_mysql_repository = ArchiveMessagesRepository(db_session)
|
archive_messages_mysql_repository = ArchiveMessagesRepository(db_session)
|
||||||
qa_milvus_repository = QARepository()
|
qa_milvus_repository = QARepository()
|
||||||
|
|
||||||
state: QueryQAState = QueryQAState(original_query="暂时有点忙", history=['2026-05-26T13:14:59 EXTERNAL 暂时有点忙\n', '2026-05-26T12:48:09 INTERNAL 下午好呀 你这边怎么计划的呀 考虑跟咱们6月1号的班级学习嘛\n'])
|
state: QueryQAState = QueryQAState(original_query="老师这个课程多少钱啊,老师 我想报班,", history=['2026-07-10T15:42:13 EXTERNAL 老师这个课程多少钱啊\n', '2026-07-10T15:40:34 EXTERNAL 老师 我想报班\n'])
|
||||||
context = QueryQAContext(meta_mysql_repository=archive_messages_mysql_repository,
|
context = QueryQAContext(meta_mysql_repository=archive_messages_mysql_repository,
|
||||||
qa_milvus_repository=qa_milvus_repository
|
qa_milvus_repository=qa_milvus_repository
|
||||||
)
|
)
|
||||||
async for chunk in qa_graph.astream(input=state, context=context, stream_mode="custom"):
|
async for chunk in qa_graph.astream(input=state, context=context, stream_mode="custom"):
|
||||||
print("=================="+chunk)
|
for item in chunk:
|
||||||
|
print("=================="+item)
|
||||||
milvus_client.close()
|
milvus_client.close()
|
||||||
await embedding_client.close()
|
await embedding_client.close()
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from langgraph.runtime import Runtime
|
from langgraph.runtime import Runtime
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
from app.chat_qa_query.query_qa_state import QueryQAState
|
from app.chat_qa_query.query_qa_state import QueryQAState
|
||||||
|
|
||||||
|
|
||||||
async def answer_output(state:QueryQAState, runtime: Runtime):
|
async def answer_output(state: QueryQAState, runtime: Runtime):
|
||||||
writer = runtime.stream_writer
|
writer = runtime.stream_writer
|
||||||
qa_pairs = state.get("qa_pairs", "")
|
qa_pairs = state.get("qa_pairs", [])
|
||||||
if qa_pairs:
|
|
||||||
writer(qa_pairs.get("answer"))
|
if qa_pairs and isinstance(qa_pairs, list):
|
||||||
|
content_list = [item.get("content", "") for item in qa_pairs if item.get("content")]
|
||||||
|
writer(content_list)
|
||||||
else:
|
else:
|
||||||
writer("")
|
writer([])
|
||||||
@ -9,9 +9,9 @@ from app.llm import llm
|
|||||||
from app.core.log import logger
|
from app.core.log import logger
|
||||||
|
|
||||||
problem_rewriting_prompt = """
|
problem_rewriting_prompt = """
|
||||||
你是一名智能客服意图理解助手。你的任务是根据用户的历史会话和当前问题,提取用户的核心诉求,并将其改写为一个语义完整、信息自包含的独立问题。
|
你是一名智能客服意图分类助手。你的任务是根据用户的历史会话和当前问题,提取用户的核心诉求,并输出标准化的意图标签。
|
||||||
|
|
||||||
## 改写规则(必须严格遵守)
|
## 分类规则(必须严格遵守)
|
||||||
|
|
||||||
1. **核心诉求提取**:
|
1. **核心诉求提取**:
|
||||||
- 从当前问题中提取用户明确表达的诉求(可能有一个或多个,去重)
|
- 从当前问题中提取用户明确表达的诉求(可能有一个或多个,去重)
|
||||||
@ -19,7 +19,7 @@ problem_rewriting_prompt = """
|
|||||||
|
|
||||||
2. **信息补全**:
|
2. **信息补全**:
|
||||||
- 如果当前问题缺少主语或关键上下文(如只问"费用多少"),必须从历史会话中提取相关主题补全
|
- 如果当前问题缺少主语或关键上下文(如只问"费用多少"),必须从历史会话中提取相关主题补全
|
||||||
- 改写后的问题必须是一个**不依赖历史会话也能被独立理解**的完整问题
|
- 确保意图标签不依赖历史会话也能被独立理解
|
||||||
|
|
||||||
3. **语义保持**:
|
3. **语义保持**:
|
||||||
- 不得改变用户的原始意图
|
- 不得改变用户的原始意图
|
||||||
@ -28,17 +28,27 @@ problem_rewriting_prompt = """
|
|||||||
|
|
||||||
4. **输出格式**:
|
4. **输出格式**:
|
||||||
- 仅输出JSON,不要任何解释性文字
|
- 仅输出JSON,不要任何解释性文字
|
||||||
|
- 输出为结构化的意图标签数组,**不要改写成完整问句**
|
||||||
|
- 每个意图标签应简洁、标准化,适合用于分类路由
|
||||||
|
- 多诉求时拆分为多个意图标签,用数组形式输出
|
||||||
|
|
||||||
## 改写示例
|
## 意图标签规范
|
||||||
|
|
||||||
|
- 每个标签格式为:"{{诉求类型}}"
|
||||||
|
- 常见诉求类型:费用咨询、课程内容、报名流程、就业支持、开班时间、校区地址、优惠活动、退款政策、住宿咨询、其他
|
||||||
|
- 标签应简洁、无歧义,便于系统直接匹配路由
|
||||||
|
- 从当前问题中提取用户明确表达的诉求,可以提取多个意图标签
|
||||||
|
|
||||||
|
## 分类示例
|
||||||
|
|
||||||
### 示例1(信息补全)
|
### 示例1(信息补全)
|
||||||
历史会话:
|
历史会话:
|
||||||
- 用户:我想了解游戏特效培训
|
- 用户:我想了解游戏特效培训
|
||||||
- 助手:好的,请问您想了解哪方面?
|
- 助手:好的,请问您想了解哪方面?
|
||||||
当前问题:培训的费用
|
当前问题:培训的费用
|
||||||
改写结果:
|
分类结果:
|
||||||
{{
|
{{
|
||||||
"rewritten_query": "游戏特效培训的费用是多少?"
|
"intents": "费用咨询"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
### 示例2(指代消解)
|
### 示例2(指代消解)
|
||||||
@ -46,40 +56,43 @@ problem_rewriting_prompt = """
|
|||||||
- 用户:你们有Python课程吗?
|
- 用户:你们有Python课程吗?
|
||||||
- 助手:有的,我们有Python基础班和进阶班。
|
- 助手:有的,我们有Python基础班和进阶班。
|
||||||
当前问题:这个课程的时长是多久?
|
当前问题:这个课程的时长是多久?
|
||||||
改写结果:
|
分类结果:
|
||||||
{{
|
{{
|
||||||
"rewritten_query": "Python课程的时长是多久?"
|
"intents": ["课程内容", "报名流程"]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
### 示例3(多诉求提取)
|
### 示例3(多诉求提取)
|
||||||
历史会话:
|
历史会话:
|
||||||
- 用户:我想报名数据分析培训
|
- 用户:我想报名数据分析培训
|
||||||
当前问题:流程、费用和就业支持
|
当前问题:流程、费用和就业支持
|
||||||
改写结果:
|
分类结果:
|
||||||
{{
|
{{
|
||||||
"rewritten_query": "数据分析培训的报名流程、费用以及就业支持分别是什么?"
|
"intents": ["报名流程"]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
### 示例4(无需改写)
|
### 示例4(无需改写)
|
||||||
历史会话:(空)
|
历史会话:(空)
|
||||||
当前问题:Java培训的课程内容有哪些?
|
当前问题:Java培训的课程内容有哪些?
|
||||||
改写结果:
|
分类结果:
|
||||||
{{
|
{{
|
||||||
"rewritten_query": "Java培训的课程内容有哪些?"
|
"intents": ["课程内容"]
|
||||||
|
}}
|
||||||
|
|
||||||
|
### 示例5(用户提供的案例)
|
||||||
|
历史会话:(空)
|
||||||
|
当前问题:老师这个课程多少钱啊,老师我想报班
|
||||||
|
分类结果:
|
||||||
|
{{
|
||||||
|
"intents": ["报名流程"]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 现在请处理以下输入
|
## 现在请处理以下输入
|
||||||
|
|
||||||
历史会话:
|
|
||||||
{history_text}
|
|
||||||
|
|
||||||
历史会话字段说明:
|
|
||||||
EXTERNAL:客户
|
|
||||||
INTERNAL:销售
|
|
||||||
当前问题:{query}
|
当前问题:{query}
|
||||||
|
历史聊天:{history_text}
|
||||||
请直接输出JSON:
|
请直接输出JSON:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -92,14 +105,16 @@ async def rewrite_query(state:QueryQAState):
|
|||||||
"""
|
"""
|
||||||
history_text = state.get("history", "")
|
history_text = state.get("history", "")
|
||||||
query = state.get("original_query", "")
|
query = state.get("original_query", "")
|
||||||
logger.info(f"问题改写:{query},历史会话:{history_text}")
|
|
||||||
prompt = PromptTemplate(template=problem_rewriting_prompt, input_variables=["history_text", "query"])
|
prompt = PromptTemplate(template=problem_rewriting_prompt, input_variables=["history_text", "query"])
|
||||||
output = JsonOutputParser()
|
output = JsonOutputParser()
|
||||||
chain = prompt | llm | output
|
chain = prompt | llm | output
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await chain.ainvoke({"history_text": history_text, "query": query})
|
result = await chain.ainvoke({"history_text": history_text, "query": query})
|
||||||
rewritten_query = result.get("rewritten_query", "")
|
rewritten_query = result.get("intents", "")
|
||||||
|
if rewritten_query:
|
||||||
|
rewritten_query = rewritten_query[0]
|
||||||
|
logger.info(f"原问题:{query},历史会话:{history_text},改写后问题:{rewritten_query}")
|
||||||
return {"rewritten_query": rewritten_query}
|
return {"rewritten_query": rewritten_query}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"QA质量检查prompt模板错误:{e}")
|
logger.error(f"QA质量检查prompt模板错误:{e}")
|
||||||
|
|||||||
@ -1,29 +1,31 @@
|
|||||||
"""
|
"""
|
||||||
向量化
|
向量化
|
||||||
"""
|
"""
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
from app.chat_qa_query.query_qa_state import QueryQAState
|
from app.chat_qa_query.query_qa_state import QueryQAState
|
||||||
from app.client.embedding_client_manager import embedding_client
|
from app.client.embedding_client_manager import embedding_client
|
||||||
|
from app.repository.milvus.intent_classification_repository import IntentClassificationRepository
|
||||||
from app.repository.milvus.message_qa_repository import QARepository
|
from app.repository.milvus.message_qa_repository import QARepository
|
||||||
from app.core.log import logger
|
from app.core.log import logger
|
||||||
|
|
||||||
|
|
||||||
async def search_embed(state:QueryQAState) :
|
async def search_embed(state: QueryQAState):
|
||||||
logger.info("search_embed")
|
logger.info("search_embed")
|
||||||
rewritten_query = state.get("rewritten_query", "")
|
rewritten_query = state.get("rewritten_query", "")
|
||||||
original_query = state.get("original_query", "")
|
original_query = state.get("original_query", "")
|
||||||
|
|
||||||
batch_embeddings = await embedding_client.aembed_query(rewritten_query)
|
#batch_embeddings = await embedding_client.aembed_query(rewritten_query)
|
||||||
qa_repository = QARepository()
|
intent_class = IntentClassificationRepository()
|
||||||
|
result: List[Dict[str, Any]] = await intent_class.search_by_intent_category(intent_category=rewritten_query, top_k=3)
|
||||||
result = await qa_repository.search_qa(query_vector=batch_embeddings, top_k=3)
|
logger.info(f"原始问题:{original_query},改写后的问题:{rewritten_query},匹配结果:{result}")
|
||||||
logger.info(f"原始问题:{original_query},改写后的问题:{rewritten_query},匹配结果:{result}")
|
|
||||||
if result:
|
qa_pairs = []
|
||||||
question = result[0]["question"]
|
for item in result:
|
||||||
answer = result[0]["answer"]
|
qa_pairs.append({
|
||||||
|
"intent_category": item.get("intent_category", ""),
|
||||||
qa_pairs={
|
"content": item.get("content", ""),
|
||||||
"question": question,
|
"score": item.get("score", 0)
|
||||||
"answer": answer,
|
})
|
||||||
}
|
|
||||||
return {"qa_pairs": qa_pairs}
|
return {"qa_pairs": qa_pairs}
|
||||||
return {}
|
|
||||||
@ -23,6 +23,11 @@ class QueryQAService:
|
|||||||
context = QueryQAContext(archive_mysql_repository=self.archive_mysql_repository,qa_milvus_repository=self.qa_milvus_repository)
|
context = QueryQAContext(archive_mysql_repository=self.archive_mysql_repository,qa_milvus_repository=self.qa_milvus_repository)
|
||||||
try:
|
try:
|
||||||
res= await qa_graph.ainvoke(input=state, context=context, stream_mode="custom")
|
res= await qa_graph.ainvoke(input=state, context=context, stream_mode="custom")
|
||||||
|
logger.info(f"QA服务结果:{res}")
|
||||||
|
|
||||||
|
if isinstance(res, list) and len(res) == 1 and isinstance(res[0], list):
|
||||||
|
res = res[0]
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -129,6 +129,7 @@ class QARepository:
|
|||||||
# res 是二维结构:[ [单条结果列表] ]
|
# res 是二维结构:[ [单条结果列表] ]
|
||||||
for hits in res:
|
for hits in res:
|
||||||
for hit in hits:
|
for hit in hits:
|
||||||
|
print("hit:", hit)
|
||||||
if hit.get("distance",0) >= score_threshold:
|
if hit.get("distance",0) >= score_threshold:
|
||||||
# 按需组装数据
|
# 按需组装数据
|
||||||
item = {
|
item = {
|
||||||
|
|||||||
1
readme
1
readme
@ -1,3 +1,4 @@
|
|||||||
|
进入虚拟环境
|
||||||
启动:nohup uvicorn app.uvicorn_main:app --host 0.0.0.0 --port 8000 --workers 1 > uvicorn.log 2>&1 &
|
启动:nohup uvicorn app.uvicorn_main:app --host 0.0.0.0 --port 8000 --workers 1 > uvicorn.log 2>&1 &
|
||||||
打包:py package.py
|
打包:py package.py
|
||||||
同步库+虚拟环境:uv sync
|
同步库+虚拟环境:uv sync
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user