Building an AI Knowledge Base — Internal Documentation Search That Actually Works

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Most companies have documentation scattered across Confluence, Notion, GitHub, and Google Docs — where employees find outdated guides, duplicate information, or nothing at all. An AI knowledge base unifies fragmented documentation into a single conversational interface, but building one that works in production requires solving ingestion pipelines, incremental sync, access control, and quality feedback loops simultaneously.

Why Most Knowledge Bases Fail

The typical failure mode is not technical — it is freshness. A knowledge base indexed once and never updated becomes worse than useless because employees stop trusting it after finding stale answers. The second failure is access control: surfacing a restricted HR document to the wrong employee destroys trust instantly.

Production knowledge bases need three things: automated ingestion that keeps content current, namespace-level access control enforced at query time, and a feedback loop that flags bad answers before users give up.

Unified Ingestion Pipeline

Build one ingestion layer that normalizes documents from all sources into a common schema:

import hashlib
import json
from dataclasses import dataclass, field
from typing import Literal
from datetime import datetime
 
@dataclass
class Document:
    id: str
    source: Literal["confluence", "notion", "github", "gdocs"]
    source_id: str
    title: str
    content: str
    url: str
    last_modified: datetime
    owner: str
    access_level: Literal["public", "team", "restricted"]
    tags: list[str] = field(default_factory=list)
 
    def content_checksum(self) -> str:
        return hashlib.sha256(self.content.encode()).hexdigest()
 
 
def ingest_confluence(domain: str, token: str, space_keys: list[str]) -> list[Document]:
    docs = []
    for space_key in space_keys:
        import requests
        resp = requests.get(
            f"https://{domain}/wiki/rest/api/content",
            params={"spaceKey": space_key, "expand": "body.storage,version,restrictions"},
            headers={"Authorization": f"Bearer {token}"},
        )
        for page in resp.json().get("results", []):
            docs.append(Document(
                id=f"confluence-{page['id']}",
                source="confluence",
                source_id=page["id"],
                title=page["title"],
                content=page["body"]["storage"]["value"],
                url=f"https://{domain}/wiki/spaces/{space_key}/pages/{page['id']}",
                last_modified=datetime.fromisoformat(page["version"]["when"].rstrip("Z")),
                owner=page["version"]["by"]["username"],
                access_level="team",
                tags=page.get("labels", {}).get("results", []),
            ))
    return docs

Incremental Sync With Content Checksums

Never re-ingest everything. Sync only what changed:

def incremental_sync(
    existing_checksums: dict[str, str],
    new_docs: list[Document],
) -> dict:
    added, updated, deleted = [], [], []
    new_checksum_map = {}
 
    for doc in new_docs:
        checksum = doc.content_checksum()
        new_checksum_map[doc.id] = checksum
 
        if doc.id not in existing_checksums:
            added.append(doc)
        elif existing_checksums[doc.id] != checksum:
            updated.append(doc)
        # else: unchanged — skip
 
    for doc_id in existing_checksums:
        if doc_id not in new_checksum_map:
            deleted.append(doc_id)
 
    return {"added": added, "updated": updated, "deleted": deleted}
 
 
def apply_sync(changes: dict, vector_db) -> None:
    for doc in changes["added"]:
        vector_db.upsert(doc.id, doc.content, {
            "title": doc.title,
            "url": doc.url,
            "access_level": doc.access_level,
            "last_modified": doc.last_modified.isoformat(),
        })
 
    for doc in changes["updated"]:
        vector_db.upsert(doc.id, doc.content, {
            "title": doc.title,
            "url": doc.url,
        })
 
    for doc_id in changes["deleted"]:
        vector_db.delete(doc_id)

Checksum-based diffing is significantly more reliable than timestamp comparison because timestamps are frequently incorrect after bulk migrations or source system clock drift.

Access-Controlled Retrieval

Enforce document permissions at query time, not at ingestion time:

def search_with_access(
    query: str,
    user_id: str,
    user_teams: list[str],
    vector_db,
    limit: int = 5,
) -> list[dict]:
    # Fetch more candidates than needed to account for access filtering
    candidates = vector_db.search(query, limit=limit * 4)
    accessible = []
 
    for doc in candidates:
        level = doc["metadata"]["access_level"]
 
        if level == "public":
            accessible.append(doc)
        elif level == "team":
            doc_teams = get_document_teams(doc["id"])
            if any(t in doc_teams for t in user_teams):
                accessible.append(doc)
        elif level == "restricted":
            allowed_users = get_restricted_users(doc["id"])
            if user_id in allowed_users:
                accessible.append(doc)
 
        if len(accessible) >= limit:
            break
 
    return accessible

Always filter after retrieval, never rely on namespace separation alone. Namespaces help performance; permission checks provide security.

Conversational Search With Source Attribution

The interface must show where answers come from:

def answer_question(
    query: str,
    user_id: str,
    user_teams: list[str],
    vector_db,
    llm_client,
) -> dict:
    docs = search_with_access(query, user_id, user_teams, vector_db)
 
    if not docs:
        return {
            "answer": "No relevant documentation found. Try rephrasing your question or contact your knowledge manager.",
            "sources": [],
        }
 
    context_parts = [
        f"[{doc['metadata']['title']}]({doc['metadata']['url']}):\n{doc['content'][:600]}"
        for doc in docs
    ]
    context = "\n\n---\n\n".join(context_parts)
 
    prompt = f"""You are a knowledge base assistant. Answer the question using only the provided documentation.
 
Question: {query}
 
Documentation:
{context}
 
Rules:
- Answer in 2-4 sentences
- Cite the source document by name with a link
- If the documentation does not fully answer the question, say so explicitly
- Do not invent information not present in the sources"""
 
    answer = llm_client.generate(prompt)
 
    return {
        "answer": answer,
        "sources": [
            {"title": d["metadata"]["title"], "url": d["metadata"]["url"]}
            for d in docs
        ],
    }

Staleness Detection

Proactively flag outdated documents before users encounter them:

def detect_stale_documents(all_docs: list[dict]) -> list[dict]:
    stale = []
    now = datetime.utcnow()
 
    for doc in all_docs:
        last_modified = datetime.fromisoformat(doc["last_modified"])
        age_days = (now - last_modified).days
        reasons = []
 
        if age_days > 365:
            reasons.append(f"Not updated in {age_days} days")
 
        # Look for year references that are now stale
        import re
        stale_years = re.findall(r"as of (202[0-3])", doc["content"], re.IGNORECASE)
        if stale_years:
            reasons.append(f"Contains stale year reference: {stale_years[0]}")
 
        if reasons:
            stale.append({
                "id": doc["id"],
                "title": doc["title"],
                "age_days": age_days,
                "reasons": reasons,
                "action": "Review and update or archive",
            })
 
    return stale

Search Analytics to Find Documentation Gaps

Track what employees search for and fail to find:

def log_search_event(
    query: str,
    user_id: str,
    result_count: int,
    clicked: bool,
    dwell_seconds: float,
    analytics_db,
) -> None:
    analytics_db.insert("search_events", {
        "query": query,
        "user_id": user_id,
        "timestamp": datetime.utcnow().isoformat(),
        "result_count": result_count,
        "clicked": clicked,
        "dwell_seconds": dwell_seconds,
    })
 
    # Zero-result searches point directly to documentation gaps
    if result_count == 0:
        analytics_db.insert("zero_result_queries", {
            "query": query,
            "timestamp": datetime.utcnow().isoformat(),
        })
 
 
def top_zero_result_queries(analytics_db, days: int = 30) -> list[dict]:
    return analytics_db.query(
        """
        SELECT query, COUNT(*) as count
        FROM zero_result_queries
        WHERE timestamp > NOW() - INTERVAL %s DAY
        GROUP BY query
        ORDER BY count DESC
        LIMIT 20
        """,
        [days],
    )

Feedback Loop for Document Quality

Let users flag unhelpful answers:

def record_feedback(
    doc_id: str,
    user_id: str,
    helpful: bool,
    comment: str,
    feedback_db,
    threshold_rate: float = 0.6,
    min_feedback: int = 5,
) -> None:
    feedback_db.insert("document_feedback", {
        "doc_id": doc_id,
        "user_id": user_id,
        "helpful": helpful,
        "comment": comment,
        "timestamp": datetime.utcnow().isoformat(),
    })
 
    # Check if this document has a consistently low helpfulness rate
    recent = feedback_db.query(
        "SELECT helpful FROM document_feedback WHERE doc_id = %s AND timestamp > NOW() - INTERVAL 30 DAY",
        [doc_id],
    )
 
    if len(recent) >= min_feedback:
        helpful_rate = sum(1 for r in recent if r["helpful"]) / len(recent)
        if helpful_rate < threshold_rate:
            flag_document_for_review(doc_id, helpful_rate, feedback_db)

Key Takeaways

  • Checksum-based incremental sync is more reliable than timestamp comparison — sync only documents whose content hash has changed.
  • Access control must be enforced at query time by filtering retrieved candidates, not by namespace separation alone, to guarantee that permission changes take effect immediately.
  • Conversational search always cites source documents with links — users who cannot verify answers will stop trusting the system.
  • Staleness detection on documents older than 365 days, or containing year references from prior years, prevents stale content from reaching users.
  • Zero-result query tracking is the fastest way to identify documentation gaps — every failed search is a content request.
  • A feedback loop that flags documents with under 60% helpfulness from five or more responses creates a self-improving quality signal.
  • Incremental sync schedules should match document churn: hourly for Confluence spaces with active editing, daily for static GitHub wikis.
  • A knowledge base with good access control but stale content is worse than no knowledge base — prioritize freshness infrastructure before expanding coverage.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro