☰
基于RAG与GraphRAG分流的智能旅游问答系统构建实战
2026/9/29 19:32:34 网站建设 项目流程

简介:面向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_k=5) 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或者本地Qwen,prompt里必须写死输出格式,否则后续解析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方便。如果只是做纯问答demo,Chroma够用,没必要一上来就上分布式向量库。

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, temperature=0) 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, key=lambda 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点拉一次当天天气缓存到Redis,TTL设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(f"weather:{city}") if cached: return json.loads(cached) try: data = await call_weather_api(city, self.config["weather_key"], self.timeout) self.cache.setex(f"weather:{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 model's 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 rate(top-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开始,再补分流准确率,最后上人工评测。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询