从保险政策草稿到AI问答Demo,我做了个信息检索器

最近看到一篇关于女性医疗保险覆盖状况的政策文章(InsuranceNewsNet,2026年6月),里面数据详实,但阅读门槛高。我花了一个周末把它变成一个可对话的AI应用——上传PDF,直接问“2024年有多少女性购买非团体保险?”它就能回复。

这篇文章分享整个实现过程,代码片段可直接复用。读完你能明白:

  • RAG(检索增强生成)在长文档问答中的真实落地
  • LangChain+ChromaDB的工程配置
  • 如何避免政策类数据问答的典型陷阱

1. 产品Demo效果

终端截图:输入问题与AI回答

你可以上传任何政策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_sizeoverlap

3. 核心代码实现

3.1 文档加载与分块

python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
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 向量存储

python
1 2 3 4 5 6 7 8 9
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 检索增强生成

python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
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. 项目结构和配置

text
1 2 3 4 5 6 7 8 9 10 11 12
├── .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

text
1 2 3 4 5 6 7 8
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)推送到前端。核心改动:

python
1 2 3 4 5 6 7 8
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是实现领域问答最经济的方式,不用训练模型、不用微调,适合快速验证。但要做好信息溯源(即回答附带文档片段引用)。原始文档的结构(段落、标题、表格)在分块时很容易丢失,我建议先用UnstructuredLlamaParse解析出Markdown格式,再做分块,能保留层级信息,检索效果更好。

想继续深化的读者,下一步可以尝试加入多文档比对(比如同一政策的不同年份报告),用MultiQueryRetriever生成多个角度的查询去检索,综合回答。那样你的保险政策问答系统就能回答“2024年相比2023年发生了什么变化”这类时间对比问题。


本文所有代码已在Python 3.12 + macOS 14.5下测试通过。openai库版本1.30.0,langchain 0.3.0。