资讯动态

Phidata + PgVector实战:零基础搭建智能RAG知识库问答助手

发布时间:2026/10/23 3:20:32 来源:尧图企业网站定制
做RAG最让我头疼的其实不是模型而是知识库和问答链路之间的那一大堆胶水代码。前阵子整理团队内部文档问答系统时我把整套流程换到了Phidata加PgVector的组合上代码量少了一半检索结果还很直观。今天就把这套智能RAG助手的完整搭建过程写出来适合已经会用Python、但不想在重量级框架的抽象里绕圈的开发者。这篇文章会带着你从零搭出一个本地知识库问答助手文档放进去之后它能根据文档内容回答你的提问并在回答里注明信息来自哪份文档的哪个片段。读完你会弄清楚三个核心问题为什么选Phidata和PgVector这套组合、知识库是怎么建起来的、跑通之后参数怎么调。我不打算把原理讲成玄学所有结论都来自我自己在实操中跑过的实验和踩过的坑。1. 为什么是Phidata和PgVector我对RAG技术栈的选型思考1.1 RAG系统最容易卡住的三个环节说句实在话RAG的难点从来不在“调用大模型”99%的翻车都发生在三个环节。第一文档加载和清洗PDF多栏排版、表格、扫描件每一项都需要单独处理处理不好后面全白搭第二切块策略切太小检索时没有上下文切太大噪音太多还容易突破模型窗口第三检索结果和prompt的拼接即使系统层面完成了向量相似度检索如果不把检索片段以清晰的格式注入上下文模型依然会自己发挥。我在搭第一版的时候就是在这三个环节里反复打转。文档清洗还好说毕竟团队文档以Markdown为主真正让我烦的是“检索-注入-生成”这条链路里的粘合逻辑。每次想加一个过滤条件或者调整上下文格式都要动好几个文件改完之后还得担心有没有破坏其他功能。所以我寻思找一个更薄的工具把Agent的调度问题解决掉把向量存储的问题交给更靠谱的基础设施。1.2 Phidata在RAG里扮演什么角色Phidata这个框架在中文技术圈不算烂大街但用过的都知道它“薄得刚刚好”。它核心是提供一个Agent抽象你定义一个Agent告诉它能用哪些工具、该守什么规矩然后它负责调度。放到RAG场景里Phidata的价值就是把“检索”变成一个工具让Agent在回答前主动去查知识库。过去手写的时候查库逻辑、拼prompt逻辑、调模型逻辑全部搅在一起改一个阈值得动三层代码。用Phidata之后检索是一个独立函数Agent只负责决定“要不要调用它、调用完之后怎么组织答案”。这个抽象很符合人的直觉你请一个实习生帮忙查资料只需要告诉他“不懂就去资料室翻一下翻完标注页码”而不是把整个资料室的索引方式都塞给他。Phidata另一个好处是轻。它不像那些重型编排框架一样动辄引入一堆抽象概念。你写一个普通Python函数把它挂到Agent的工具列表里事情就成了。这种“薄”意味着出了问题你能直接看源码不会在一个黑盒里猜来猜去。1.3 PgVector把向量仓库放在业务数据库旁边意味着什么再说PgVector。它是一个PostgreSQL扩展给数据库增加了向量类型和向量索引支持L2距离、内积、余弦相似度检索。选择它的逻辑并不复杂小团队维护一套PostgreSQL是家常便饭如果为了RAG再单独部署一套专用向量数据库运维成本直接翻倍索引同步、权限管理、冷热备份全都要多操心。PgVector直接把向量表和业务表放进同一个数据库你甚至可以在一条SQL里既做向量相似度检索又做字段过滤。比如“只查2025年部门内部制度相关的内容”这种需求一个WHERE条件就解决了数据同步环节都省了。可能有人担心性能向量数据量到百万量级时PgVector配合HNSW索引也扛得住。而大多数内部文档库离百万chunk还远得很用专用向量库属于杀鸡用牛刀。我自己对比过几种方案这里把取舍列出来供参考对比维度专用向量数据库PgVector部署成本需要单独维护一套服务复用已有PostgreSQL数据一致性往往需要双写业务表和向量表业务表与向量表天然同库级别过滤部分产品支持但语法各不兼容标准SQL即可完成百万级以下性能优势不明显足够上手掌管成本高低所以如果你是个人开发者或者小团队知识库规模没有大到上亿级别PgVector真的是性价比极高的选择。2. 环境准备把PostgreSQL和PgVector跑起来2.1 安装PostgreSQL并启用扩展环境准备这一步没啥玄学但恰恰是很多人卡住的地方。我用的PostgreSQL版本是15PgVector扩展要求PostgreSQL 14以上低于14装都装不上。先装数据库本体# Ubuntu/Debian系 sudo apt install postgresql postgresql-contrib # macOS系 brew install postgresql装完启动服务进入命令行sudo -u postgres psql然后创建一个给RAG用的独立数据库避免跟其他业务混在一起CREATE DATABASE rag_demo; \c rag_demo接着启用向量扩展。注意必须在你要用的数据库里执行光在默认的postgres库里启用没用CREATE EXTENSION IF NOT EXISTS vector;执行完可以用一条最简单的SQL验证SELECT [1,2,3]::vector - [4,5,6]::vector AS distance;如果返回结果大约是5.196说明扩展已经就绪数据库这边算是通了。2.2 Python依赖清单Python侧的依赖不复杂核心就是四个包pip install phidata pgvector sqlalchemy psycopg2-binary这里有个小坑连接串的写法取决于你用哪个PostgreSQL驱动。如果用了psycopg2-binary连接串要写成postgresqlpsycopg2://用户名:密码localhost:5432/rag_demo如果你用了psycopg这个新驱动连接串则要写成postgresqlpsycopg://用户名:密码localhost:5432/rag_demo我最初就是在这里翻车的。SQLAlchemy 2.x要求连接串里必须带驱动名直接写postgresql://启动时就会报NoSuchModuleError这种错很容易让人误以为是环境没装好实际上只是协议字符串写得不完整。2.3 建库建表向量字段的DDL长什么样数据库建表是整个RAG链路的地基。我建议给知识库单独建一张表字段包括主键、文档名、片段序号、原文内容以及最重要的向量字段CREATE TABLE document_chunks ( id BIGSERIAL PRIMARY KEY, doc_name VARCHAR(255) NOT NULL, chunk_index INTEGER NOT NULL, content TEXT NOT NULL, content_vector VECTOR(1536) ); CREATE INDEX idx_chunks_vector ON document_chunks USING hnsw (content_vector vector_cosine_ops);VECTOR(1536)里的1536是我用的Embedding接口返回的向量维度不同模型维度不一样换模型时一定要同步改这里。索引选择vector_cosine_ops因为我在查询时用的余弦距离这个索引能和查询操作符对应上。如果用SQLAlchemy建表等价写法是这样from sqlalchemy import create_engine, Column, Integer, String, Text, BigInteger from sqlalchemy.orm import declarative_base, Session from pgvector.sqlalchemy import Vector Base declarative_base() class DocumentChunk(Base): __tablename__ document_chunks id Column(BigInteger, primary_keyTrue, autoincrementTrue) doc_name Column(String(255), nullableFalse) chunk_index Column(Integer, nullableFalse) content Column(Text, nullableFalse) content_vector Column(Vector(1536)) engine create_engine(postgresqlpsycopg2://用户名:密码localhost:5432/rag_demo) Base.metadata.create_all(engine)我选BIGSERIAL做主键而不是UUID就一个原因内部系统的知识库表不需要分布式ID自增主键插入性能更好代码还简单。3. 开始搭从一堆文档到可检索的知识库3.1 文档加载先别急着上PDF解析很多人搭RAG上来就想啃PDF我的建议是第一批知识库别一上来就上PDF。先把Markdown和纯文本跑通原因是PDF解析的坑非常多多栏排版、扫描件OCR、表格识别每一项都能耗你一个下午。等基础流程稳定了再逐步引入PDF解析器也不迟。我推荐先准备一个目录把文档按固定结构放好data/docs/ 运维手册.md 备份规范.md 值班制度.txt然后用Python递归扫目录from pathlib import Path DOC_ROOT Path(data/docs) files [] for ext in (*.md, *.txt): files.extend(DOC_ROOT.rglob(ext)) print(f发现 {len(files)} 个文档) for f in files: print(f.name)这一步不需要太聪明只要把文件路径收集起来就够了。每条路径背后都对应一份真实的知识来源后面入库时要把文档名一起存进去方便溯源。3.2 切块比单纯数token更可靠的策略切块是个看着简单、实际影响很大的环节。我见过不少方案是按固定字符数硬切比如每500字一刀切下去结果一段话被从中间拦腰截断语义直接断裂检索效果自然好不了。我最终采用的是“按段落合并”的策略先识别文档里的空行把内容拆成段落再把段落合并到接近目标长度。这样每个chunk内部至少是完整段落语义完整性高很多。import re def split_into_chunks(text: str, max_chars: int 800): paragraphs re.split(r\n{2,}, text.strip()) chunks [] current [] current_len 0 for p in paragraphs: p p.strip() if not p: continue if current_len len(p) max_chars and current: chunks.append(\n\n.join(current)) current [] current_len 0 current.append(p) current_len len(p) if current: chunks.append(\n\n.join(current)) return chunks这里的max_chars800是首次实验的经验值。800字符对中文文档来说大约能覆盖两到三个自然段落既不会因为太短丢失上下文也不会因为太长让向量中心偏移。后面我会单独讲切块大小对检索效果的影响这里先记住这个值。3.3 向量化挑选Embedding模型的三个硬指标Embedding模型决定知识库的“理解能力”选的时候我只看三个硬指标输出维度是否稳定、中文语义效果如何、接口成本和限流是否可接受。我现在用的模型返回1536维向量也就是建表时那个1536的来历。Embedding调用的封装很简单核心函数就一个传入一段文本返回一个浮点数组。批量场景下要特别注意限流一次性喂1000段容易被接口拒掉我习惯把批次控制在32段左右def embed_text(text: str) - list[float]: # 这里替换成你自己使用的Embedding服务调用 response embedding_client.embedding(text) return response[data][0][embedding] def embed_batch(texts: list[str]) - list[list[float]]: results [] for i in range(0, len(texts), 32): batch texts[i:i32] results.extend([embed_text(t) for t in batch]) return results有一点必须提醒你的Embedding模型一旦定了就别轻易换。换模型意味着向量维度可能变化即使维度恰好一样不同模型产生的向量空间也不同强行混合使用会导致检索结果极其不稳定。我在这上面吃过亏后面避坑部分会细说。3.4 入库向量和原文一起存加载、切分、向量化做好之后最后一步是把chunk连同向量写进数据库。这里有一个性能注意点不要每插入一条就commit一次应该攒一批一起提交不然几十万字下来耗时能差十倍以上。with Session(engine) as session: for doc_path in files: text doc_path.read_text(encodingutf-8) chunks split_into_chunks(text, max_chars800) vectors embed_batch(chunks) for i, (chunk, vec) in enumerate(zip(chunks, vectors)): session.add(DocumentChunk( doc_namedoc_path.name, chunk_indexi, contentchunk, content_vectorvec, )) session.commit() print(f已入库 {doc_path.name}: {len(chunks)} 个片段)入库之后你可以顺手做一次抽样检查随便挑几个chunk看看切块结果是否合理、原文有没有被截断。我自己的习惯是每入库一个新文档都打印前三个chunk的内容看一眼避免切块策略在新文档格式下失效。4. 把检索变成Agent的一个工具4.1 检索函数最核心的一段代码知识库建好之后接下来要做的是把“查知识库”封装成一个函数。这个函数接收用户问题返回最相关的几个知识片段。我用的查询是余弦距离排序from sqlalchemy import text def search_knowledge(query: str, top_k: int 5) - str: 从知识库检索与问题最相关的片段。 Args: query: 用户的问题原文 top_k: 返回的片段数量 Returns: 拼接好的知识片段文本包含来源标记。 query_vector embed_text(query) sql_query text( SELECT doc_name, chunk_index, content, (1 - (content_vector :query_vector)) AS similarity FROM document_chunks ORDER BY content_vector :query_vector LIMIT :top_k ) with engine.connect() as conn: rows conn.execute( sql_query, {query_vector: query_vector, top_k: top_k} ).fetchall() if not rows: return 知识库中未检索到相关内容。 parts [] for r in rows: similarity r.similarity if similarity 0.55: continue parts.append(f[来源{r.doc_name} 片段{r.chunk_index}]\n{r.content}) if not parts: return 知识库中未检索到足够相关内容。 return \n\n---\n\n.join(parts)是PgVector提供的余弦距离操作符距离越小越相似所以用1 - 距离转成相似度便于理解。阈值0.55是我在测试语料上跑出来的经验值不代表所有场景都适用后面会专门讲调阈值的方法。4.2 用Phidata定义带工具的Agent检索函数就位后终于轮到Phidata登场。Agent的配置核心是两件事告诉它有哪些工具可用告诉它回答问题的行为边界。from phi.agent import Agent INSTRUCTIONS [ 当用户的提问与知识库内容相关时必须先调用 search_knowledge 获取资料。, 回答时只能依赖 search_knowledge 返回的内容不要添加知识库之外的假设。, 如果检索结果为空直接说“知识库中没有找到对应资料”不要编造答案。, 回答末尾必须用 [来源文档名 片段序号] 列出实际参考的片段。, ] agent Agent( modelyour_model, # 换成你环境可用的模型服务 tools[search_knowledge], instructionsINSTRUCTIONS, show_tool_callsTrue, )这里有个细节值得展开Phidata会把函数的docstring自动解析成工具描述所以search_knowledge的注释写得越清楚Agent就越知道这个工具是干嘛的。我在docstring里明确写了“知识库”和“问题”这些关键词它的意图识别就会更准确。show_tool_callsTrue是调试阶段必须开的开关。打开之后Agent每次调用工具都会在输出里显示你能直接看到它查了什么、基于什么信息回答。等系统稳定了再关掉也不迟。4.3 一个最小可用的问答脚本配置好Agent之后你只需要一个最简单的交互循环就能跑起来if __name__ __main__: while True: q input(你的问题) if q.strip().lower() in (exit, quit): break agent.print_response(q)我第一次跑通这个脚本的时候说实话有点意外——整个RAG助手的核心逻辑检索函数加Agent配置加起来不到80行。但能用和好用之间还有很长一段距离网上大量资料只展示“能跑”的那一步很少讲“怎么调好”。接下来这几节才是重点。4.4 调试让Agent把“查了什么”说出来第一次运行时别急着看最终答案先观察工具调用过程。开了show_tool_callsTrue后Agent的输出会包含类似这样的内容Running: search_knowledge(query备份文件保留多久)确认两点第一Agent是否真的调用了检索第二传入的query是否准确反映了用户的意图。如果发现某些明显和知识库相关的问题Agent却没有调用检索多半是instructions里的约束不够强硬可以加上“本系统所有回答都应当先检索知识库”这类语句。还有一个实用技巧在search_knowledge函数内部临时加上打印把返回的片段数和相似度列出来。这样即使Agent最终回答得不好你也能判断问题出在“没检索到”还是“检索到了但没用上”。这两个原因的处理方向完全不同。5. 实测对比同一批问题看看RAG到底带来了什么5.1 六个测试问题为了验证这套系统的真实效果我准备了一份模拟运维手册大约40页内容包含值班安排、告警分级、备份策略、账号权限申请流程等。切块后得到420个片段平均每段约800字符。我挑了三类典型问题来测试第一类是精确步骤类比如“夜间收到告警电话第一步应该做什么”文档里有明确操作顺序第二类是数字类比如“备份文件保留多久”答案是一个具体天数第三类是跨段落推理类比如“登录服务负责人不在怎么办”需要把分布在多个章节的信息串联起来。5.2 裸模型与RAG回答对照问题类型裸模型回答特点RAG助手回答特点精确步骤类泛泛而谈只说“先确认告警真实性”之类的通用建议按文档顺序给出具体三步操作附片段来源数字类编造了一个“保留3个月”的回答正确回答“30天”并标注“备份规范.md 片段12”跨段落推理类笼统建议“联系相关负责人”给出完整的升级路径但偶尔遗漏局部细节开放概念类能回答但无出处能回答且每条关键信息都有引用超出知识库范围自信地编造了流程明确说“知识库中没有找到对应资料”模糊提问类按最常见的理解直接回答有时会反过来向用户确认更具体的含义5.3 三个典型结果分析最惊艳的是数字类问题。裸模型碰到“备份保留多久”这种问题时会基于训练数据里的常见情况编一个答案听起来合理但不可验证。RAG助手则直接命中文档原文精确度天上地下。启发最大的是跨段落推理类问题。RAG助手回答“登录服务负责人不在怎么办”时给出的升级路径是对的但漏掉了文档里“需同步抄送值班组长”这个动作。原因不是模型幻觉而是这个信息分布在另一个chunk里只取Top5时没被捞出来。这不是模型的问题是召回覆盖的问题。解决办法是提高top_k或者引入重排序精排后面会细说。至于模糊提问类我原本以为RAG也容易踩坑实测下来反而发现它比裸模型更谨慎。原因是RAG的回答基于具体文档当问题指代不明时它能感知到检索结果可能不匹配反而会选择追问澄清。这种“知道自己不知道”的行为在裸模型上很少见到。6. 调优与踩坑我把这套系统调稳的全过程6.1 chunk_size对召回的影响三次实验对比我对同一个语料跑了三组切块实验分别是300字符、800字符、1500字符结果差别非常明显切块大小命中率回答完整度主要问题300低低语义经常被截断片段间上下文断裂800高高平衡得很好偶有噪音1500中中片段太宽向量中心不聚焦300字符的切法频繁出现“一段话被从中间截断”的情况。检索时能命中片段但片段里只有问题需要的前半段内容后半段答案在另一个chunk里模型拼不出来。1500字符的切法则走向另一个极端一个片段里包含三四个主题向量被平均之后和哪个主题都不够贴近检索精度反而下降。如果你的文档结构清晰还可以更进一步以Markdown的标题为硬边界标题下的内容最多合并到800字符。这样每个chunk在语义上天然独立比纯按长度切效果好一个档次。6.2 相似度阈值与top_k距离分布怎么看“相似度阈值大于0.7才算相关”这种经验值我建议别直接套用。每个Embedding模型的相似度分布差异很大同一个模型在不同语料上也有不同表现。正确做法是抽样观察距离分布。我在调阈值时做了这样一件事挑10个典型问题逐个跑search_knowledge把每条返回结果的相似度记下来人工判断哪些是真正相关的。连续记录几十条之后就能发现我这个语料库上正确答案的相似度正常落在0.55到0.85之间0.5以下的基本都是无关内容。于是阈值定在0.55比较稳。top_k的话我建议起步用5文档片段偏短的可以加到8。太小容易漏太大则会把大量无关片段塞进上下文既稀释模型注意力又浪费token。6.3 重排序粗召回加精排的两级方案当文档库变大之后只用向量检索的短板会慢慢暴露相似度最高的片段未必是最需要的那段。这时候可以上两级检索先用向量检索粗召回top50再用一层精排逻辑从50个候选中挑出最终top5。精排可以选择一个轻量模型对“问题-片段”的相关性打分也可以先用BM25做一遍关键词匹配把两种排序做加权融合。但我不建议一上来就搞重排序它属于锦上添花。先把切块大小、阈值、top_k这三件事调明白系统效果通常就已经能满足日常使用了。6.4 五张“踩坑卡”问题、现象、根因、解法实操中我记录了几个典型的坑直接列成表问题现象根因解法扩展装不上CREATE EXTENSION vector 报错PostgreSQL版本低于14升级数据库或用包管理器安装匹配版本连接串报错SQLAlchemy报NoSuchModuleError协议串少写了驱动名改成postgresqlpsycopg2://维度不一致写入时报different vector dimensions换了Embedding模型没重建表确认维度重建表或迁移数据检索结果全是噪音相似度列表里混着大量无关片段阈值设得太低或太低质chunk按距离分布重定阈值回答仍编造检索为空但模型给出“看起来合理”的答案指令约束不够硬instructions里加“检索为空必须明说”其中维度不一致这个错我遇到过不止一次。有一次顺手换了一个更便宜的新Embedding模型忘了旧的表字段是1536维新模型返回768维写入时数据库直接报错。最稳妥的办法是把Embedding模型当成知识库的“协议约定”定了就不要轻易改。6.5 给后来者的默认参数备忘如果你看完前面这些想直接先跑通再慢慢调这是我目前用着比较顺手的一套默认参数EMBEDDING_DIM 1536 CHUNK_SIZE 800 CHUNK_OVERLAP 80 TOP_K 5 SIMILARITY_THRESHOLD 0.55最后一个提醒每换一个语料库不要迷信旧阈值。我每次都会先跑10个典型问题观察相似度分布再决定阈值。如果语料里重复文本多先做一遍去重否则检索结果会被内容几乎相同的片段刷屏看着命中率高实际提供不了额外信息。最后分享一个我自己的小习惯每次跑通之后我会把Agent回答过的问题连同它引用的片段一起存一份日志。下次调参时直接翻日志看哪些答案因为检索不准而变差比闷头调数值高效很多。这套Phidata加PgVector的组合我前后用了好几个月至少对我这种“一个人维护内部工具”的场景来说已经足够顺手。如果你也在搭知识库问答建议先按这篇文章跑通最小闭环再决定要不要上重排序、混合检索这些进阶手段。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑