摘要
员工反复问年假、报销,HR成人肉FAQ。本文用RAG+Function Calling+多轮记忆搭文档问答:文档自动分块入ChromaDB,Agent自主决定何时检索,回答标来源,追问懂上下文。
先看一段对话:
👤 年假有几天? 🤖 入职满1年5天,满3年10天,满5年15天。 📌 出自:公司制度FAQ.txt 第4段 👤 能顺延吗? 🤖 年假当年未休完可顺延至次年3月31日。 📌 出自:公司制度FAQ.txt 第4段 👤 出差住宿呢? 🤖 一线城市500元/晚,其他城市400元/晚。 📌 出自:公司制度FAQ.txt 第6段注意第二轮"能顺延吗"——没有主语、没有关键词,但系统知道问的是年假。这不是关键词匹配能做到的。
这个系统整合了前面学过的三样东西:RAG(Day07-08)负责找文档,Function Calling(Day02)负责让Agent自己决定什么时候搜,多轮记忆(Day05)负责理解"能顺延吗"指什么。
系统怎么工作的
跟Day07朴素RAG的区别是:Agent自己决定要不要搜。用户说"你好",LLM不调用搜索工具直接回复;用户问"年假几天",LLM自动调用search_documents(query="年假天数")。这个决策由Function Calling完成,不需要写if-else判断。
文档入库:分块是关键
文档上传时做三件事:读取文本、按500字符切块(带50字符重叠)、调GLM embedding-3生成向量存入ChromaDB。
importchromadbfromopenaiimportOpenAIclassEmbeddingFn:"""GLM向量嵌入,ChromaDB调用这个类把文本转向量"""def__init__(self):self.client=OpenAI(api_key=os.environ["GLM_API_KEY"],base_url="https://open.bigmodel.cn/api/paas/v4/")self.model="embedding-3"def__call__(self,input):ifisinstance(input,str):input=[input]vecs=[]foriinrange(0,len(input),64):resp=self.client.embeddings.create(model=self.model,input=input[i:i+64])vecs.extend(d.embeddingfordinresp.data)returnvecsclassDocStore:def__init__(self,path="./chroma_db"):client=chromadb.PersistentClient(path=path)self.col=client.get_or_create_collection("docs",embedding_function=EmbeddingFn())defadd(self,text,source):chunks=self._chunk(text)self.col.add(documents=chunks,ids=[f"{source}__{i}"foriinrange(len(chunks))],metadatas=[{"source":source,"idx":i}foriinrange(len(chunks))])returnlen(chunks)def_chunk(self,text,size=500,overlap=50):chunks=[]start=0whilestart<len(text):chunks.append(text[start:start+size].strip())ifstart+size>=len(text):breakstart+=size-overlap# 回退overlap字符,避免截断句子returnchunksdefsearch(self,query,n=3):r=self.col.query(query_texts=[query],n_results=n)returnlist(zip(r["documents"][0],r["metadatas"][0]))分块时50字符的重叠区是为了防止一句话被切成两半。比如"年假满1年享有5天"如果被切成"年假满1年"和"享有5天"两块,检索哪块都可能语义不完整。重叠区保证关键句至少完整出现在一个块里。
每个块带着source和idx元数据,回答时就能标注"出自哪个文件第几段"。
Agent:Function Calling驱动检索
Agent的核心是一个循环——LLM决定调工具,执行后把结果喂回去,直到LLM给出最终回答:
importjsonfromllm_clientimportLLMClient TOOLS=[{"type":"function","function":{"name":"search_documents","description":"搜索公司制度文档,返回相关片段。当用户问到公司制度、福利、流程等问题时调用。","parameters":{"type":"object","properties":{"query":{"type":"string","description":"检索关键词,简洁明确"}},"required":["query"]}}}]SYSTEM="""你是公司制度问答助手。根据检索到的文档片段回答问题。 如果检索结果不相关或不充分,直接说没找到,不要编造答案。 回答时在末尾标注引用来源。"""classDocQAAgent:def__init__(self):self.llm=LLMClient(provider="glm")self.store=DocStore()self.history=[]defask(self,question):messages=[{"role":"system","content":SYSTEM}]messages.extend(self.history[-10:])# 最近10轮对话messages.append({"role":"user","content":question})sources=[]for_inrange(3):# 最多循环3次resp=self.llm.client.chat.completions.create(model=self.llm.model,messages=messages,tools=TOOLS,temperature=0.3)msg=resp.choices[0].messageifnotmsg.tool_calls:# LLM给出最终回答answer=msg.content self.history.append({"role":"user","content":question})self.history.append({"role":"assistant","content":answer})returnanswer,sources# LLM要求调用搜索工具fortcinmsg.tool_calls:args=json.loads(tc.function.arguments)results=self.store.search(args["query"])context="\n\n".join(f"[片段{i+1}] 出自{m['source']}第{m['idx']}段:\n{doc}"fori,(doc,m)inenumerate(results))sources.extend(m["source"]for_,minresults)messages.append({"role":"tool","tool_call_id":tc.id,"content":context})多轮记忆就靠self.history列表。第二轮"能顺延吗"发出时,历史里有第一轮的问答,LLM知道"顺延"的主语是年假,会自动用"年假 顺延"作为检索关键词,不需要用户重复。
跑起来
agent=DocQAAgent()# 首次运行,上传一份制度文档agent.store.add(open("公司制度FAQ.txt",encoding="utf-8").read(),"公司制度FAQ.txt")# 开始问答whileTrue:q=input("\n👤 你: ").strip()ifq=="exit":breakanswer,src=agent.ask(q)print(f"🤖{answer}")ifsrc:print(f"📌 引用来源:{', '.join(set(src))}")👤 你: 年假有几天? 🤖 入职满1年5天,满3年10天,满5年15天。 📌 引用来源:公司制度FAQ.txt 👤 你: 能顺延吗? 🤖 年假当年未休完可顺延至次年3月31日。 📌 引用来源:公司制度FAQ.txt 👤 你: 你好 🤖 你好!我是公司制度问答助手,可以问我年假、报销、请假等问题。第三轮"你好"没有触发搜索,LLM直接回复了——这就是Function Calling的价值,Agent自己判断什么时候需要查文档。
跟Day07朴素RAG的区别
| 维度 | Day07 朴素RAG | Day16 文档问答Agent |
|---|---|---|
| 检索触发 | 每问必搜 | LLM自己判断要不要搜 |
| 检索词 | 用户原句直接搜 | LLM改写为更精准的关键词 |
| 多轮追问 | 不支持,每轮独立 | 带历史,理解"它"“这个”“能顺延吗” |
| 引用来源 | 无 | 标注文件名和段落 |
| 打招呼/闲聊 | 也会去搜文档 | LLM直接回复不搜 |
完整版支持PDF和Word文档上传(PyPDF2/python-docx解析)、按文档类型分集合检索、回答后LLM自评置信度,放在CSDN下载区。
GLM-4.7-Flash永久免费,embedding-3费用极低(一篇文档入库约几分钱),ChromaDB开源免费。
下一篇Day17做研究助手:给一个课题,Agent自主拆解、搜索、反思查漏、最后生成完整报告。
本文由「梅雅达编程笔记」原创,首发CSDN。
AI Agent实战系列共18篇,从Function Calling到多Agent协作,全程用免费模型GLM-4.7-Flash,点个关注不迷路✨
CSDN博客:https://blog.csdn.net/2601_96428997/