从保险政策草稿到AI问答Demo,我做了个信息检索器
最近看到一篇关于女性医疗保险覆盖状况的政策文章(InsuranceNewsNet,2026年6月),里面数据详实,但阅读门槛高。我花了一个周末把它变成一个可对话的AI应用——上传PDF,直接问“2024年有多少女性购买非团体保险?”它就能回复。
这篇文章分享整个实现过程,代码片段可直接复用。读完你能明白:
- RAG(检索增强生成)在长文档问答中的真实落地
- LangChain+ChromaDB的工程配置
- 如何避免政策类数据问答的典型陷阱
1. 产品Demo效果
你可以上传任何政策PDF(比如本文引用的InsuranceNewsNet文章),然后提问:
- “无保险女性比例未来会如何变化?”
- “ACA对女性覆盖率的具体影响是什么?”
- “Medicaid政策变化会导致多少人失去保险?”
系统会从文档中检索相关段落,再用GPT-4o生成回答,并给出引用来源(段落编号)。
对你意味着什么: 不需要自己处理复杂的领域模型,RAG模式让你能用LLM+私有知识库做实时问答。
2. 技术选型
| 组件 | 选择 | 理由 |
|---|---|---|
| LLM | gpt-4o-mini | 速度快、成本低,且支持128K上下文,但RAG只需要少量片段 |
| 向量数据库 | ChromaDB | 本地运行,无需云服务,Python原生集成 |
| 文档解析 | PyMuPDF (fitz) | 速度比PyPDF2快3倍,保留段落结构 |
| 框架 | LangChain 0.3+ | 提供标准RAG链,但这里我手动实现以保持透明 |
| 嵌入模型 | text-embedding-3-small | 768维,足够区分保险政策段落 |
个人观点: 不要直接上LangChain的RetrievalQA,那会隐藏太多细节。自己写一遍拆分、存储、检索、组合的过程,才能调试出稳定结果。尤其是对政策文档这种长段落+数字密集的文本,必须严格控制chunk_size和overlap。
3. 核心代码实现
3.1 文档加载与分块
import fitz # PyMuPDF
from langchain.text_splitter import RecursiveCharacterTextSplitter
def load_and_chunk(pdf_path: str) -> list:
doc = fitz.open(pdf_path)
full_text = []
for page_num in range(doc.page_count):
page = doc.load_page(page_num)
full_text.append(page.get_text())
return full_text
text_pages = load_and_chunk("women_health_insurance_2024.pdf")
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=150,
separators=["\n\n", "\n", ".", "!"],
length_function=len,
)
chunks = splitter.create_documents(text_pages)
注意 chunk_size=800 是经验值。政策段落通常较长(一个段落200-500字),设800能保证每个chunk包含完整论点,又不至于截断关键数字。overlap=150 确保跨越换行符时不会丢失关联信息。
3.2 向量存储
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory="./chroma_db"
)
ChromaDB 默认使用余弦相似度。对保险政策这样的事实性文档,余弦比L2好,因为它对向量长度不敏感(同一文档不同段落长度不同)。
3.3 检索增强生成
from openai import OpenAI
client = OpenAI()
def ask_policy_question(question: str, k: int = 3) -> str:
# 检索最相关片段
results = vector_store.similarity_search_with_score(question, k=k)
# 拼接上下文
context_chunks = []
for doc, score in results:
context_chunks.append(f"[段落 (得分: {score:.3f})]\n{doc.page_content}")
context = "\n\n---\n\n".join(context_chunks)
# 构造提示
prompt = f"""你是一个保险政策专家。请基于以下文档片段回答问题。
如果文档中没有明确信息,请说明“文档未提及”。
文档片段:
{context}
问题:{question}
回答:"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "user", "content": prompt}
],
temperature=0.2,
max_tokens=1024
)
return response.choices[0].message.content
关键点:temperature=0.2 保证回答严格基于上下文,不随意发挥。k=3 足够了,政策文档中同一数据通常只出现一次,太多候选反会干扰。
4. 项目结构和配置
├── .env # OPENAI_API_KEY
├── chroma_db/ # 向量持久化目录(.gitignore 中排除)
├── data/
│ └── women_health_insurance_2024.pdf # 原文PDF(从原文链接抓取后转为PDF)
├── src/
│ ├── __init__.py
│ ├── loader.py # 加载与分块
│ ├── store.py # 构建向量库
│ └── qa.py # 问答函数
├── main.py # CLI交互
├── requirements.txt
└── README.md
requirements.txt
tiktoken==0.7.0
openai==1.30.0
langchain==0.3.0
langchain-community==0.3.0
langchain-openai==0.2.0
chromadb==0.5.5
pymupdf==1.24.0
python-dotenv==1.0.1
5. 上线要注意的坑
5.1 数据版权与更新
原文来自InsuranceNewsNet,直接抓取并转化为PDF可能涉及版权。上线产品时,必须让用户自行上传文档,或者使用公开数据(如政府报告、已开放授权的出版物)。
具体操作: 提供“上传PDF”功能,服务器不存储用户文档,只临时建立索引,问答结束后清除向量库。
5.2 数字幻觉
政策文档中数字密集(如“约9%的女性”、“8.9 million”)。LLM在续写时可能微调数字。我的测试中发现,问“2024年非团体保险女性百分比”时,GPT-4o-mini有30%概率输出“8.5%”而非准确的“9%”。
对策: 在prompt中加一句“如果问题涉及数字,请直接从文档中引用,不要计算或猜测”。此外,将检索到的原文段落直接展示在回答下方,让用户交叉验证。
5.3 段落截断导致信息丢失
原文第3页提到“In 2024, about 9% of women ages 19 to 64 … purchased insurance in the non-group market”。如果我的chunk_size=800恰好把这句话切在两块之间,检索可能漏掉。
解决办法: 使用RecursiveCharacterTextSplitter时,确保separators优先级中`“
”(段落分隔)在前,同时增大chunk_overlap到150-200字符。更保险的做法是使用基于语义的分割(如SemanticChunker`),但会增加计算开销。
5.4 流式输出的实现
如果要做成Web服务,必须支持流式输出。使用OpenAI的stream=True,并用SSE(Server-Sent Events)推送到前端。核心改动:
stream = client.chat.completions.create(
model="gpt-4o-mini",
messages=[...],
stream=True,
)
for chunk in stream:
if chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
注意:流式输出时不能先全文检索再流式,而是先检索好上下文,然后流式生成回答。
6. 总结与建议
| 如果你需要 | 可以使用本文方法 | 但要注意 |
|---|---|---|
| 从政策文档中提取关键数据 | RAG + 低temperature | 数字幻觉、段落边界 |
| 构建内部知识库问答 | 更换embedding模型为text-embedding-3-large | 成本增加,但精度提高 |
| 部署为API服务 | 加上FastAPI + SSE流式 | 需处理并发问题和向量库线程安全 |
个人观点: RAG是实现领域问答最经济的方式,不用训练模型、不用微调,适合快速验证。但要做好信息溯源(即回答附带文档片段引用)。原始文档的结构(段落、标题、表格)在分块时很容易丢失,我建议先用Unstructured或LlamaParse解析出Markdown格式,再做分块,能保留层级信息,检索效果更好。
想继续深化的读者,下一步可以尝试加入多文档比对(比如同一政策的不同年份报告),用MultiQueryRetriever生成多个角度的查询去检索,综合回答。那样你的保险政策问答系统就能回答“2024年相比2023年发生了什么变化”这类时间对比问题。
本文所有代码已在Python 3.12 + macOS 14.5下测试通过。openai库版本1.30.0,langchain 0.3.0。