ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于RAG与GraphRAG分流的智能旅游问答系统构建实战

基于RAG与GraphRAG分流的智能旅游问答系统构建实战 简介面向RAG应用开发与智能旅游平台建设者这份实施计划资源包提供了从架构到落地的完整参照重点解决个性化问答、路线规划、多格式解析与服务集成等问题。包内共九十六个文件整体十点九六兆类型覆盖城市数据表、前端组件、后端脚本、数据库与配置文件等同时包含向量模型数据兼顾数据层与服务层目录清晰便于分模块拆解。已有九十人浏览学习。通过项目可了解检索增强生成与图增强生成如何分流动态路径如何依据偏好与实时位置生成多格式文档解析管线如何设计并获取接口服务代码、向量库构建流程、数据处理与数据库管理脚本以及可交互前端页面实施计划中还体现技术实现、用户体验与数据安全的综合考量。对想上手智能问答系统开发的个人或团队具备方案参考与二次开发价值。1. 智能旅游问答不是重新发明RAG基于现有系统加一个分流层才是核心很多团队接到“智能旅游问答平台”这个需求时第一反应是重新搭一套RAG把景区文档、攻略、票务信息全喂进去然后让用户像用ChatGPT一样提问。这个思路不算错但落地一段时间就会发现旅游问答根本不是单链路检索能覆盖的用户既会问“XX景区几点关门”这种事实型问题也会问“带老人孩子玩三天怎么安排”这种需要关系推理和路径规划的问题还会在雨天追问“今天适合去哪”。基于现有RAG系统去升级核心不是换检索框架而是在入口加一层智能分流——RAG与GraphRAG各管一段个性化推荐和动态路径规划接力外部API负责补实时信息。这篇实施笔记把这条路径的每个模块、参数和坑拆开讲适合正要动手做同类平台的工程师直接参照。2. RAG与GraphRAG智能分流先解决“这个问题该问谁”2.1 纯RAG在旅游问答里的四个失效时刻常见的做法是拿现成的RAG框架直接喂旅游文档跑一周就会发现四类问题。第一类是关系推理问题。用户问“从颐和园到圆明园怎么坐车最方便”文档里可能有“颐和园东门出发地铁4号线到圆明园站”这类描述但如果你只做了段落级向量检索召回结果往往是“颐和园简介”“圆明园历史”真正描述两地交通的段落被淹没在top-k之外。第二类是约束组合问题。“带老人孩子玩三天、预算三千、不爬山”需要同时满足多个条件筛选景点组合向量检索擅长找相似的段落不擅长做满足约束的集合。第三类是时效问题。门票是否需要预约、景区是否临时关闭这类信息在文档里可能存在但已经过期纯RAG没有时效概念会把过期的“开放夜场”当成当前事实回答。第四类是连续追问。用户先问“杭州有什么好玩的”再追问“那第二天下雨怎么办”后一个问题没有出现地点实体独立检索会直接丢上下文。这四个失效时刻对应着同一个结论不是“换更大的模型”能解决的。GraphRAG的价值在于把文档里的实体和关系显式建图回答“A到B怎么到”“哪些景点适合老人”这类查询时沿图结构走一跳或两跳比纯向量召回稳定得多。把两类检索能力合到一起就引出分流问题同一个问题走哪条链路或者两条都跑再合并。2.2 三路分流信号意图、实体与召回置信度我给平台做的分流器是一个轻量路由模块挂在RAG入口之前整体架构是“输入层 → 路由层 → 检索层 → 生成层”。路由层接收三个信号输出五类决策graph走图谱链路vector走纯向量链路hybrid两条都跑再融合plan交给推荐与路径规划模块api接实时外部数据。后两类是业务侧定义好的前两类是检索核心分流。信号一是问题意图。先用分类器判断这是一个事实型问题还是规划型问题。事实型如“某酒店电话多少”“景点门票价格”规划型如“帮我安排两天一夜的行程”。实际操作我用LLM做零样本分类给一个少样本示例要求返回json字段成本可控准确率够用。信号二是实体类型。用NER抽实体重点看地点实体之外有没有“老人”“孩子”“轮椅”“下雨”“预算”这些约束词。出现人群与约束词优先走graph和plan只出现地点和时间词走vector。可以用spaCy也可以让LLM顺带抽取旅游领域自定义实体表更可靠规则和模型结合最稳。信号三是召回置信度。向量检索回来top-k结果里如果最高分低于阈值说明知识库可能没有对应内容此时不硬答转graph或提示需要实时查询。阈值跟embedding模型强相关我后面会讲到怎么标定。三个信号不是投票制是优先级制意图为plan直接走plan意图为api且带天气类实体走api意图为fact时看召回置信度够高走vector、不够高走graph意图为complex时直接hybrid。这个优先级顺序不能乱plan和api排在前面是为了避免规划类问题被误判成复杂检索浪费算力。2.3 分流路由的实现与参数路由模块我用一个async Python服务实现核心逻辑不复杂async def route_question(question: str, context: dict) - dict: intent await classify_intent(question) # fact / complex / plan / api entities extract_entities(question) # 地点、人群、预算、天气词 if intent plan: return {route: plan, reason: 规划型问题交给行程模块} if intent api or (天气 in question and 今天 in question): return {route: api, reason: 需要实时数据不允许走静态知识库} if intent fact: hits await vector_search(question, top_k5) if hits[0].score 0.62: return {route: vector, reason: 事实型召回置信度高} else: return {route: graph, reason: 事实型但向量召回弱尝试图谱} if intent complex: return {route: hybrid, reason: 复合约束双链路合并}这段代码的判定顺序是重点。classify_intent和extract_entities都是LLM在线调用实现的因为旅游领域问题表达太随意“老人孩子”这类主语规则很难穷举。用LLM抽取之后把结果缓存到Redis同义问题可以复用。这里的LLM不追求大7B到14B的模型足够qps上来之后再换小模型蒸馏。外部LLM我一般用deepseek api或者本地Qwenprompt里必须写死输出格式否则后续解析json容易翻车。参数上需要注意三个召回置信度阈值、分类用的模型、路由结果缓存时间。阈值我写的0.62是用text-embedding-3-small在自建数据集上标出来的——拿200个已知答案的问题跑检索统计正确命中和错误命中的分数分界。换embedding模型或者换领域数据集这个值一定重标这是我调过最多次的参数。graph和vector两条链路的结果不能盲目拼接。graph出的实体路径和vector出的段落文本格式完全不同拼在一起很容易出现“前半句在说实体关系、后半句在粘贴文档原文”的割裂感。最终采用的策略是用graph结果做骨架确定应该讲哪几个实体、什么顺序再用vector段落做血肉给每个实体配对应的原文描述最后由LLM统一生成。这个策略比“把两路top-k全部丢给LLM”稳定graph保证逻辑连贯vector保证信息密度LLM只做组织不做取舍幻觉空间被压小。3. 多格式文档智能解析从PDF/Word/网页到可检索的知识底座3.1 文档解析流水线没有统一解析器只有按格式拼装的流水线旅游平台的知识来源非常杂景区官方PDF游览须知、导览图扫描件、Word版行程单、网页公告、Excel门票价格表。一个通用解析器是不存在的所谓多格式文档智能解析落到实现上是四套解析任务的拼装。PDF分两类处理文本型PDF用PyMuPDF直接抽文本扫描型PDF走OCR。PyMuPDF的抽取速度比pdfplumber快一个量级但表格结构会丢所以涉及表格的页面我会额外用pdfplumber的表格抽取逻辑单独跑。Word文档用python-docx但很多行程单把内容写进文本框python-docx默认读不到需要遍历XML里的文本框节点。网页用trafilatura比BeautifulSoup稳它对正文提取做了大量清洗直接能拿到干净文本。def parse_document(file_path: str) - list[dict]: ext file_path.rsplit(., 1)[-1].lower() if ext pdf: return parse_pdf(file_path) elif ext docx: return parse_docx(file_path) elif ext in (xlsx, xls): return parse_excel(file_path) else: return parse_web(file_path)这层包装看着简单实际每个分支内部都有分支。parse_pdf里要先用PyMuPDF判断页面是否有文本字符数少于50的页自动进入OCR流程parse_docx要额外hook文本框节点parse_excel要处理合并单元格。核心原则是解析结果统一成带元信息的dict字段包括content正文、source原始文件路径、page页码或章节号、table_type表格行列结构。元信息在切分和建图时都有用。如果项目里用到MinerU这类文档解析API建议把解析层抽象成接口本地解析器与API服务可以随时切换避免被单一厂商绑死。3.2 切片与向量化标题感知切分比固定窗口效果好文档解析完是半结构化文本下一步是切成检索单元。旅游文档的段落独立性很强——介绍颐和园的段落、介绍圆明园的段落、介绍两地交通的段落每段都是完整语义。硬按固定512字符切会把一个完整介绍切成两半检索时召回片段缺少上下文。我用的方案是标题感知切分。先把文档按标题层级拆景区介绍文档通常有“开放时间”“交通指南”“门票信息”等二级标题按标题把内容分组每个二级标题下是一个候选检索单元如果某个标题下内容超过800字再按段落边界二次切分保持每个单元在200到800字之间。切分时把标题拼回内容前面形如“【颐和园】开放时间旺季6:30-18:00……”这样向量检索时标题信息参与语义匹配命中率提升明显。向量化这一层embedding模型的选择直接决定后面的召回质量。旅游领域中文文本不算特别生僻通用中文embedding模型就够用重点在于查询侧和文档侧的文本风格差异。用户查询往往口语化“故宫几点关门”文档是书面语“故宫博物院开放时间为……”风格差异会拉低余弦相似度。缓解办法是查询侧做一次轻量改写把口语query规范成文档风格再进embedding。用LLM做改写成本不高hit rate涨得明显——我实测同一数据集改写前hit rate在0.58左右改写后到了0.71。向量库选型小规模起步用Chroma即可数据量到百万级段落再迁Qdrant或pgvector。我实际部署用的是pgvector理由是旅游平台的用户数据、订单数据都在PostgreSQL里向量和业务数据放一起做个性化召回时join方便。如果只是做纯问答demoChroma够用没必要一上来就上分布式向量库。3.3 建图与索引质检GraphRAG的知识底座不是自动生成的GraphRAG落地最费人工的地方在实体关系抽取。从解析好的文档里抽三元组实体-关系-实体可以直接让LLM做def extract_triples(doc_chunk: str) - list[dict]: prompt f从以下旅游文档中抽取实体关系三元组。 要求实体限定为景点、交通方式、人群、时间、门票类型 关系限定为located_in/has_facility/suitable_for/reachable_by/open_time。 只返回JSON数组每个元素是{{head:实体,relation:关系,tail:实体}}。 文档{doc_chunk} resp llm_call(prompt, temperature0) return parse_json(resp)关系类型限定在五个固定值这是刻意为之。旅游图谱如果让LLM自由发挥关系会出现“位于”“毗邻”“靠近”“挨着”这些同义关系图查询时不得不做关系归一化。限定关系集之后查询也好写找“颐和园到圆明园怎么到”图查询是匹配景点节点之间的reachable_by关系cypher可以模板化。这一步做完GraphRAG的“图谱”才真正可用否则只是把文本换了个格式存起来。图谱存储我用Neo4j建图频率不高每周增量跑一次就够。时效性强的“临时闭园”“预约政策”不进图谱这类信息由时效模块管理。质检是建图过程中最容易偷懒也最后悔的地方——必须抽样看三元组。我见过一次全量建图跑完抽取出的三元组有30%是错的比如把“老人免票”抽成了“老人位于公园”。质检做法是按实体类型抽样每个类型抽20条人工看错误率超过5%就调prompt重新抽绝不直接入库。索引质检还要包括embedding的召回自测从知识库里抽300个问题-答案对跑检索统计hit rate和MRR。每次调整切分策略或换embedding模型先跑这个脚本看指标变化再决定要不要上线。4. 个性化推荐与动态路径规划把“景点介绍”变成“可执行行程”4.1 用户画像不是猜喜好是收集约束个性化推荐系统在旅游问答里最容易做成“猜你喜欢”但真正决定一个行程是否可用的不是喜好是约束。用户带着老人或孩子、预算三千、时间三天、从哪个城市出发——这些是硬约束。喜好是软约束爱吃辣、喜欢古镇、不愿走太多路。我把画像做成两层约束模板硬约束进结构化字段软约束进偏好标签。硬约束的获取来自两个渠道一是用户在问答里直接给的信息“带老人”“预算三千”二是前端提供的筛选条件。前者需要从对话里抽沿用第2节的NER模块把抽取结果回填到会话上下文的profile对象里。软约束靠历史问答和新一轮对话的追问累积。这里不需要做到推荐系统论文里的协同过滤和矩阵分解FAQ级问答场景里画像的复杂度没有想象中高。画像的作用体现在最终生成环节路径规划模块输出的多套候选方案在生成回答时按画像过滤和排序。比如同样的“北京四日游”有老人孩子时爬山类景点降权博物馆、公园类加权预算有上限时高门票景点被砍。这个环节不需要单独的推荐服务放在路由层之后的过滤函数里即可。4.2 动态路径规划贪心加约束校验LLM负责编排但不负责算路动态路径规划是听过最多“用LLM直接生成行程”然后翻车的模块。LLM生成的行程表面合理一旦校验地理位置和时间就会出现“上午在故宫、下午在八达岭”这种完全不可执行的结果。我的做法是LLM从候选POI里选点但路径计算交给规则引擎。步骤分四步。第一步从GraphRAG召回候选POI按景点类型、区域、人群适配评分。第二步按用户画像过滤不适配老人的、超出预算的、当日闭馆的全部剔除。第三步用贪心算法做路径排序从用户的出发点开始每次选择当前时间窗口内可达且评分最高的POI计算通勤时间和游览时间推进时间线。第四步把排好的路径塞给LLM让LLM写推荐理由和注意事项。def plan_route(pois: list[dict], profile: dict, start_time: str) - list[dict]: start_point profile[hotel] timeline [] cursor {pos: start_point, time: start_time} unused [p for p in pois if profile_filter(p, profile)] while unused and len(timeline) profile[days] * 3: best min(unused, keylambda p: travel_time(cursor[pos], p[loc])) est estimate_visit(best, cursor[time], profile[pace]) if not within_day(est[end]): break best[arrive] est[start] best[leave] est[end] timeline.append(best) cursor {pos: best[loc], time: est[end]} unused.remove(best) return timeline这段代码的关键在travel_time和estimate_visit。travel_time不能只算直线距离要用地图API的实际通勤时间公共交通和驾车的时间都要。estimate_visit按POI类型给基准游览时长博物馆给2.5小时、公园给1.5小时再乘上人群系数——带老人乘1.2、带小孩乘1.3。这是路径规划里最像玄学的部分系数只能通过真实用户反馈迭代没有一个公式能算出“老人走多慢”。profile[pace]就是人群系数的汇总默认1.0带老人孩子时动态调高。动态体现在两个地方一是天气API返回下雨时当天室外POI全部降权推荐室内的替代POI二是用户中途说“累了不想去”重新调用规划函数把当前时间点和位置传进去剩余POI重排。这两类动态都依赖外部API的实时数据下一章讲接法。4.3 行程与RAG问答的融合输出结构统一不做自由发挥最后一步是把行程方案和问答文本统一成回复。我踩过的坑是分开生成——先让LLM写一段介绍再让路径模块生成列表拼起来前言不搭后语。正确做法是给LLM一个结构化上下文包包括规划好的时间线JSON、每个POI的知识库描述、天气信息和用户画像然后让LLM一次性生成完整回答。回答的结构以时间线为主体每个时间点下带知识库描述和推荐理由最终用Markdown或JSON下发到前端前端决定渲染成列表还是地图。这一步的工程重点是让规划结果可被校验。无法被机器校验的方案就是黑匣子。生成之后加一个校验器检查返回的时间线是否按时间递增、每个POI是否在候选集里、是否有未处理的硬约束冲突。校验不通过重新生成最多重试两次。重试仍失败则降级为普通RAG回答不给用户返回错误行程。5. 外部API集成与常见问题排查三种接入模式、降级与五个翻车点5.1 天气、通勤、票务三类API为什么不能用一个接法外部服务API集成在旅游问答里主要覆盖三类天气数据、地图通勤、票务预约信息。三类的调用频率和实时性要求完全不同接入模式不能一样。天气API适合预取加缓存。每天的天气变化不频繁早上8点拉一次当天天气缓存到RedisTTL设2小时用户问“今天下雨吗”直接读缓存不触发外部调用。地图通勤API适合按需调用路径规划时才查两点间通勤时间结果缓存24小时因为通勤时间在一天内相对稳定。票务API最麻烦余票和预约状态实时变化需要每次问答实时调用但第三方接口不稳定必须做降级和超时控制。5.2 API接入层封装缓存优先、兜底兜住三类API的接入建议统一放在api_client层不散落在业务代码里。每一类API封装成“优先缓存命中则返回未命中调外部失败用兜底数据”的结构class TourismAPIClient: def __init__(self, cache: Redis, config: dict): self.cache cache self.config config self.timeout config.get(timeout, 3.0) async def get_weather(self, city: str) - dict: cached self.cache.get(fweather:{city}) if cached: return json.loads(cached) try: data await call_weather_api(city, self.config[weather_key], self.timeout) self.cache.setex(fweather:{city}, 7200, json.dumps(data)) return data except Exception: fallback self.config[weather_fallback].get(city) return fallback or {weather: unknown, temperature: None}这段代码的逻辑是缓存优先避免每次问答都打到第三方TTL设7200秒天气数据两小时内可接受。timeout设3秒超过就放弃本次调用不让外部接口拖垮整个问答流程。fallback是知识库里的一份静态基线数据比如“晴天/多云/雨”的保守默认值没有缓存也没有实时数据时返回unknown由生成层决定怎么措辞。5.3 五个高频翻车点的现象与处理翻车点一调用LLM或天气API返回401 Unauthorized错误信息类似“unexpected status 401 unauthorized: incorrect api key provided”。现象就是配置的key被认为无效。原因通常是key写死在代码里线上换key时漏了环境变量或者key在第三方平台被重置。解决方式所有外部API的key统一收口到密钥管理服务不在代码和配置仓库出现明文拿到新key先写一个最小调用脚本验证通过再接入主代码。这个错误我至少见过三次每次都在换key之后。翻车点二LLM API返回400错误提示“this models maximum context length is 1048576 tokens”。虽然报的是超长但根因往往不是真的超出上限而是把多格式文档解析后的长文本直接拼进了prompt。解决方式是坚持检索后生成只把命中片段拼进上下文外部API的返回也做截断天气数据只保留温度和降水字段通勤数据只保留时长和距离绝不整包塞给LLM。翻车点三地图API返回的通勤时间和实际严重不符。原因是开发时用直线距离代替了实际路线距离。直线距离1公里的两个景点没有直达公交时实际通勤可能40分钟。解决方式是统一用地图API的通勤时间字段并区分驾车和公共交通路径规划里travel_time函数必须走真实API。翻车点四票务API在节假日流量突增时超时导致整个问答流程卡住。原因是没有给外部调用设置超时。解决方式是所有外部调用统一3秒超时超时后返回缓存数据缓存也没有时回答里明确传达“目前无法确认实时信息”不编造余票状态。翻车点五如果你用Dify这类低代码RAG框架做底座会碰到“dify unstructured api url is not configured for doc file processing”这类报错。原因是文档解析服务没有单独配置Dify默认的unstructured端点没启动。解决方式是在框架配置里显式指定unstructured服务的API地址或者绕过这个服务直接用自建的解析层解析完成后把结果写回知识库。这类问题排查起来最费时间因为报错信息指向的是框架内部依赖不是你的代码。5.4 降级策略宁可说不知道不说错的兜底数据的定位是“仅供参考”静态基线里明确标注时效信息以现场为准。天气API挂了就回复“当前无法获取实时天气建议出行前查看当地预报”不要用上个月的天气数据冒充实时数据。票务API挂了回复“余票状态无法确认建议前往官方渠道查询”不要返回“有票”。这个原则执行到位能避免大部分由于外部依赖不稳定导致的错误回答。平台该兜的是可用性不是兜数据的正确性用户对“查不到”的容忍度远高于“查到假数据”。6. 上线前验证分流准确率、hit rate与端到端回归这部分是最后一道工序做不好前面全白干。我给平台搭了三层评估。第一层是离线检索评估。准备300到500条问答对每条标注标准答案对应的知识库文档ID。跑检索统计hit ratetop-5内是否命中和MRR第一个命中位置的倒数。这个指标直接影响切分策略和embedding模型的选择。查询改写那一节我提到不确定有没有效果跑完hit rate从0.58涨到0.71才敢上线。第二层是分流准确率评估。把评估集按路由标签分成五类vector/graph/hybrid/plan/api逐条跑路由模块看决策与标注是否一致。这层指标要单独算否则分流错了但最终回答碰巧对会掩盖问题。我习惯每两周重标一次评估集把线上真实的badcase补充进去防止评估集和线上分布偏差越来越大。第三层是端到端人工评测。离线指标过了不代表用户体验好。保留一个20条测试用例的固定回归集每次更新知识库、调prompt、改路由策略后人工看一遍输出质量重点看两类回答是否引用了正确的知识库来源路径规划是否可执行。LLM输出没有统一的自动评测能替代人工至少现阶段没有。另外一个值得跟踪的指标是agentic rag场景下的工具调用成功率——把天气API、地图API当成工具交给LLM调度时统计调用参数是否正确、返回是否被正确使用。我现在已经把路由从简单分流往这个方向演进但评估仍然沿用这三层只是把“路由决策”换成了“工具选择决策”。我的习惯是每两周跑一轮完整回归每次改配置都留下评估记录。上一个版本的评估结果就是下一个版本的基线没有基线就无法判断“变好还是变差”。这三层评估不需要一次全搭先从hit rate开始再补分流准确率最后上人工评测。希望帮到你。本文还有配套的精品资源点击获取
返回列表