☰
Phidata + PgVector实战:零基础搭建智能RAG知识库问答助手
2026/10/23 3:20:22 网站建设 项目流程

做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版本是15,PgVector扩展要求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,连接串要写成:

postgresql+psycopg2://用户名:密码@localhost:5432/rag_demo

如果你用了psycopg这个新驱动,连接串则要写成:

postgresql+psycopg://用户名:密码@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_key=True, autoincrement=True) doc_name = Column(String(255), nullable=False) chunk_index = Column(Integer, nullable=False) content = Column(Text, nullable=False) content_vector = Column(Vector(1536)) engine = create_engine("postgresql+psycopg2://用户名:密码@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_chars=800是首次实验的经验值。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:i+32] 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(encoding="utf-8") chunks = split_into_chunks(text, max_chars=800) vectors = embed_batch(chunks) for i, (chunk, vec) in enumerate(zip(chunks, vectors)): session.add(DocumentChunk( doc_name=doc_path.name, chunk_index=i, content=chunk, content_vector=vec, )) 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( model=your_model, # 换成你环境可用的模型服务 tools=[search_knowledge], instructions=INSTRUCTIONS, show_tool_calls=True, )

这里有个细节值得展开:Phidata会把函数的docstring自动解析成工具描述,所以search_knowledge的注释写得越清楚,Agent就越知道这个工具是干嘛的。我在docstring里明确写了“知识库”和“问题”这些关键词,它的意图识别就会更准确。

show_tool_calls=True是调试阶段必须开的开关。打开之后,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_calls=True后,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协议串少写了驱动名改成postgresql+psycopg2://
维度不一致写入时报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的组合我前后用了好几个月,至少对我这种“一个人维护内部工具”的场景来说,已经足够顺手。如果你也在搭知识库问答,建议先按这篇文章跑通最小闭环,再决定要不要上重排序、混合检索这些进阶手段。

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

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

立即咨询