feat: 知识库查询按照意图分类查询

This commit is contained in:
qinyong@9artedu.com 2026-07-13 17:56:01 +08:00
parent 430141b550
commit 23ec184ab0
11 changed files with 401 additions and 49 deletions

View File

File diff suppressed because one or more lines are too long

View 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())

View File

@ -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))

View File

@ -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()

View File

@ -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([])

View File

@ -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}")

View File

@ -1,8 +1,11 @@
""" """
向量化 向量化
""" """
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
@ -12,18 +15,17 @@ async def search_embed(state:QueryQAState) :
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:
question = result[0]["question"]
answer = result[0]["answer"]
qa_pairs={ qa_pairs = []
"question": question, for item in result:
"answer": answer, qa_pairs.append({
} "intent_category": item.get("intent_category", ""),
"content": item.get("content", ""),
"score": item.get("score", 0)
})
return {"qa_pairs": qa_pairs} return {"qa_pairs": qa_pairs}
return {}

View File

@ -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:

View File

@ -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
View File

@ -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